B-1 The freeze was one-directional and left the reverse hole wide open. Rows sync; at the 90-day durable age-out the cached rows are pruned, the rollup stops being dropped and serves again under a key that was never sent; the session is long past settle, so it pushes an aggregate on top of the per-request spans the receiver already holds. Reframed around the thing that actually matters: a copilot session's input/cache leaves this machine in one of two SHAPES — the raw rollup (`copilot:<sid>:shutdown:`), or reconciled output (rows plus `:shutdown-residual:`, which are disjoint by construction and together are exactly the rollup re-expressed). The receiver must never hold both. Whichever shape a session was first synced in, it stays in, and the other is frozen for that session permanently — in both directions. Growth WITHIN the sent shape is untouched, because same-shape output is additive, never substitutive: a resumed session's new rows and residuals still push if rows were sent, a new leg's rollup still pushes if rollups were. This also corrects the previous commit, which classed a residual as aggregate and so froze it for a session whose rows had gone out — the residual is reconciled output and belongs with the rows. B-2 A session with an unparseable timestamp was held forever while the CLI promised it would "push once it settles". Nothing could ever settle it. Settle now reads the newest moment a session can be SHOWN active; a session with nothing datable at all is sent rather than held. Mixed sessions still settle on their datable stamps. B-3 A stamp implausibly far in the future is broken data, not evidence the session is live, so it no longer counts toward that newest moment. One year-ahead row can no longer hold a month-old session hostage. Ordinary clock skew is absorbed by a one-hour grace in both directions and still reads as live. B-4 The residual dedup key carried the leg's POSITION. Legs sort across every cached file for a session, so an earlier leg arriving later renumbers every residual after it — and a renamed key is a span the receiver takes a second time, since there is no retraction for a usage span. Keyed by the leg's own instant instead: append-only files mean it never moves, and the equal-timestamp coalescing above makes it unique per leg. Residuals are new in this change and have never shipped, so no CACHE_VERSION concern. B-5 The dry-run "already synced" count now subtracts frozen too, matching the nothing-to-push line. Tests: symmetric freeze from the rows side (rollup frozen, residual and new rows still sent, per-turn untouched); all-unparseable sent, partly-unparseable still held; a year-ahead stamp ignored while a one-minute skew still holds; residual keys named after their leg instant with the positional names gone. The key-prefix pin picks up the residual's new tail. (c2) pins the property rather than one insertion scenario, because a second file's leg currently collides on the rollup's own dedup key before it can reach the residual sweep — the reachable repro would prove nothing about the next one, and that is stated at the test. Flakes: (f2) and (sc) re-bucket each run 10x isolated and 10x with four busy loops pinning cores — 40/40 clean. Neither has clock or ordering dependence at its margins: afterEach removes both tmpHome and the cache dir, and the two relative timestamps in the re-bucket test are 35 days apart so they cannot share a month. The likeliest cause of the transient failures is this branch's own commits rewriting src/parser.ts under a concurrent vitest. (f2) gains a self-check that its fingerprint sentinel really differs from the computed one, which is the one way it could have passed while exercising nothing.
9.5 KiB
codeburn sync
Push your AI usage telemetry to a shared backend so teams can track adoption, budgets, and ROI across developers.
Everything stays local-first: codeburn never sends data without your explicit action, and prompts/code are never included.
Quick Start
# One-time setup (opens browser for login)
codeburn sync setup https://metrics.your-team.com
# Push recent usage
codeburn sync push
# Check status
codeburn sync status
Commands
codeburn sync setup <url>
Configures sync with a remote endpoint. Opens your browser for a one-time OIDC login.
codeburn sync setup https://metrics.your-team.com
What happens:
- Fetches server configuration from
<url>/.well-known/codeburn-export.json - Opens your browser to the identity provider's login page
- After login, stores a refresh token securely in your OS keychain
- Saves the endpoint configuration (no secrets) to
~/.config/codeburn/sync.json
You only need to do this once. The token refreshes silently on every push.
codeburn sync push
Sends unsent AI usage data to the configured endpoint.
# Push unsent calls from the last 7 days (default)
codeburn sync push
# Push a larger window
codeburn sync push --since 30d
# Preview what would be sent
codeburn sync push --dry-run
# Also push git attribution (opt-in — see "Git attribution" below)
codeburn sync push --attribution
codeburn sync status
Shows the current sync configuration and authentication state.
Endpoint: https://metrics.your-team.com
Traces path: /v1/traces
Issuer: https://auth.your-team.com
Auth: configured
Token storage: keychain
Last sync: 2h ago
codeburn sync logout
Removes stored credentials and revokes the token at the identity provider.
codeburn sync logout
codeburn sync reset --confirm
Clears the sent-ledger, causing the next push to re-send all data in the window. Use after a backend migration or if you suspect missing data.
codeburn sync reset --confirm
What Gets Sent
Each AI interaction becomes one OTLP span with these attributes:
| Field | Example | Description |
|---|---|---|
ai.provider |
kiro, cursor, claude |
Which AI tool |
ai.model |
claude-sonnet-4-6 |
Model used |
ai.input_tokens |
12500 |
Prompt tokens |
ai.output_tokens |
3200 |
Response tokens |
ai.cost_usd |
0.085 |
Estimated cost |
ai.project |
my-app |
Project name |
ai.tools |
["Edit", "Bash"] |
Tools invoked |
A pseudonymous device_id distinguishes your machines without revealing hostnames.
Git attribution (opt-in: --attribution)
codeburn sync push --attribution additionally sends the session→commit correlation that codeburn yield computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted:
codeburn.session.attribution — one per session with joinable evidence:
| Field | Example | Description |
|---|---|---|
ai.session_id |
abc123… |
Session (shares the usage spans' traceId) |
ai.project |
my-app |
Project name |
git.repo |
github.com/acme/widget |
Normalized origin remote (credentials and ports stripped) |
git.pr_links |
["…/pull/12"] |
PR URLs captured for the session |
git.commit_count |
2 |
Number of attributed commits |
codeburn.commit — one per commit attributed to a session:
| Field | Example | Description |
|---|---|---|
git.sha |
4f2a… |
Commit SHA |
git.in_main |
true |
Whether the commit landed in the main branch |
git.was_reverted |
false |
Whether a later commit reverted it |
Attribution is inferred (timestamp-window correlation, the same heuristic as codeburn yield); the resource attribute codeburn.attribution_methodology: timestamp-window marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by (git.repo, git.sha) and session spans by ai.session_id (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with git.commit_count: 0 (a retraction), so summing git.commit_count across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the --since window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current.
With --attribution, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous codeburn.device_id resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, /org/repo/pull/N path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (ext::…, codecommit::…) are rejected outright rather than parsed. Precisely what is and is not sent:
- Commits: only from repos with a network
originremote, and only for sessions whose own project path resolved to that repo. Local-only repos,file://remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from. - PR links: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
- Without the flag, none of this is sent.
What is NOT sent
- Prompts — your actual messages to AI are never included
- Code — file contents, diffs, and paths stay local
- Bash commands — may contain secrets, never sent
- Your name/email — identity is derived server-side from your login token
There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is --attribution (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above.
Authentication
Sync uses standard OIDC (the same protocol as "Sign in with Google"). Your team's admin sets up the identity provider — you just click through the browser login once.
- Token storage: macOS Keychain, Windows Credential Manager, or Linux libsecret. Falls back to a
0600file if no keychain is available. - Token lifetime: typically 30–90 days (set by your admin). You'll be prompted to re-login when it expires.
- Re-login: run
codeburn sync setup <url>again.
FAQ
Q: Does sync run automatically?
A: No. You run codeburn sync push when you want. A future version may offer opportunistic push (after each codeburn report), but it's always explicit.
Q: What if I push the same data twice? A: Safe. A local sent-ledger tracks what's been sent. Re-pushing the same window doesn't create duplicates.
Q: Why is today's Copilot usage missing from my dashboard?
A: Copilot input/cache usage is reconciled locally between the per-request session-store.db rows and the session.shutdown rollups, and that reconciliation keeps changing while a session is live — a rollup residual shrinks as the rows covering it land, and a row that looked like a crash-only request becomes supplementary once its journal entry appears. The sent-ledger is append-once, so a value sent mid-reconciliation could never be corrected at the receiver. A Copilot session is therefore held back until it has been quiet for 24 hours, then pushed once, final. Nothing is dropped; --dry-run reports how many calls are held.
Q: Why didn't my old Copilot sessions resync with the new per-request breakdown?
A: On purpose. A Copilot session's input/cache can leave your machine in one of two shapes — as one session.shutdown rollup span per (session, model), or reconciled into one span per API request plus a residual for whatever the rows don't cover. They describe the same tokens, so the receiver must never hold both, and a usage span cannot be retracted once the append-once ledger has sent it. Whichever shape a session was first synced in, it stays in; the other is frozen for that session permanently. That runs in both directions: a session synced by a pre-store version keeps its rollup and never sends rows, and a session synced as rows never sends a rollup later — which matters because at the 90-day durable age-out the cached rows are pruned and the rollup starts serving again under a key that was never sent. Growth within the shape a session already uses is unaffected, and per-turn output spans are never frozen. --dry-run reports the frozen count. codeburn sync reset --confirm clears the local ledger and re-pushes everything under the new breakdown — only do that if the receiver's copy is cleared too, or you get exactly the doubling this avoids.
Q: What if I'm offline for a week?
A: Next push catches up. The default window is 7 days; use --since 30d or --since all (up to 6 months) for longer gaps. A push runs to completion regardless of size — server rate limits (429) are waited out automatically.
Q: Can my admin see my prompts? A: No. Prompts are never included in the payload. The server only sees token counts, costs, model names, and project names.
Q: How do I stop syncing?
A: codeburn sync logout removes everything. Or just stop running push.