Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

### Added (CLI)
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".

### Fixed (CLI)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
Expand Down
35 changes: 34 additions & 1 deletion docs/sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ 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`
Expand Down Expand Up @@ -95,14 +98,44 @@ Each AI interaction becomes one OTLP span with these attributes:

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 `origin` remote, 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.
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

Expand Down
87 changes: 76 additions & 11 deletions src/sync/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
} from './auth.js'
import { createCredentialStore } from './credentials.js'
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
import { collectUnsentCalls, sendBatches, batchCalls, MAX_PER_PUSH } from './push.js'
import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
import { batchAttributionItems } from './otlp.js'

export function registerSyncCommands(program: Command): void {
const sync = program
Expand Down Expand Up @@ -226,7 +227,8 @@ export function registerSyncCommands(program: Command): void {
.description('Push unsent telemetry data to the configured endpoint')
.option('--since <period>', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d')
.option('--dry-run', 'Show what would be sent without sending')
.action(async (opts: { since: string; dryRun?: boolean }) => {
.option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.')
.action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => {
const config = readSyncConfig()
if (!config) {
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
Expand Down Expand Up @@ -277,6 +279,18 @@ export function registerSyncCommands(program: Command): void {
// Flatten + filter against sent-ledger
const { allCalls, unsent } = collectUnsentCalls(projects)

// Attribution records (opt-in): session→commit correlation computed
// locally from the same parsed projects. Reuses the yield engine.
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
let attributionTotal = 0
if (opts.attribution) {
const { computeAttributionRecords } = await import('../yield.js')
const records = computeAttributionRecords(projects, range, process.cwd())
const collected = collectUnsentAttribution(records)
attributionUnsent = collected.unsent
attributionTotal = collected.allItems.length
}

if (opts.dryRun) {
const toPushCount = Math.min(unsent.length, MAX_PER_PUSH)
const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0)
Expand All @@ -285,10 +299,19 @@ export function registerSyncCommands(program: Command): void {
if (unsent.length > MAX_PER_PUSH) {
process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`)
}
if (opts.attribution) {
const toPushAttr = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
const commits = toPushAttr.filter(i => i.kind === 'commit').length
const sessions = toPushAttr.filter(i => i.kind === 'session').length
process.stderr.write(`[dry-run] Attribution: ${attributionTotal} facts total, would push ${toPushAttr.length} (${sessions} sessions, ${commits} commits)\n`)
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
process.stderr.write(`[dry-run] ${attributionUnsent.length - MAX_ATTRIBUTION_PER_PUSH} more attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit — a second push would be needed\n`)
}
}
return
}

if (unsent.length === 0) {
if (unsent.length === 0 && attributionUnsent.length === 0) {
process.stderr.write(`Nothing to push (${allCalls.length} calls already synced).\n`)
updateLastSync()
return
Expand All @@ -302,15 +325,18 @@ export function registerSyncCommands(program: Command): void {

// Batch and send (loops until done; waits out 429 rate limits)
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
const endpoint = `${config.baseUrl}${config.tracesPath}`

const result = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
log: msg => process.stderr.write(`${msg}\n`),
})
let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 }
if (toPush.length > 0) {
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
result = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
log: msg => process.stderr.write(`${msg}\n`),
})
}

if (result.outcome === 'auth-rejected') {
process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n')
Expand All @@ -323,11 +349,50 @@ export function registerSyncCommands(program: Command): void {
process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`)
}

// Attribution spans ride the same endpoint after the usage push
// completes. Skipped when the usage push hit rate limits or server
// errors — the endpoint is already unhappy; both retry on next push.
let attrResult: PushResult | null = null
if (opts.attribution && attributionUnsent.length > 0) {
if (result.outcome === 'complete') {
// Safety valve, mirroring the usage-call cap
const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
process.stderr.write(`${attributionUnsent.length} attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit. Pushing first ${MAX_ATTRIBUTION_PER_PUSH}; run again to continue.\n`)
}
const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size)
attrResult = await sendAttributionBatches({
endpoint,
accessToken: tokens.access_token,
batches: attrBatches,
log: msg => process.stderr.write(`${msg}\n`),
})
if (attrResult.outcome === 'auth-rejected') {
process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n')
process.exit(1)
}
if (attrResult.outcome === 'rate-limited') {
process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`)
}
if (attrResult.outcome === 'server-error') {
process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`)
}
} else {
process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`)
}
}

// Update lastSync
updateLastSync()

// Summary
process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`)
if (attrResult) {
const attrSuffix = attrResult.outcome !== 'complete'
? ` (push incomplete — remainder retries next push)`
: attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`)
}
if (result.totalRejected > 0) {
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
}
Expand All @@ -337,7 +402,7 @@ export function registerSyncCommands(program: Command): void {

// Non-zero exit when the push did not complete, so cron/scripts can
// detect it. Ledgered progress is kept; next push resumes.
if (result.outcome !== 'complete') {
if (result.outcome !== 'complete' || (attrResult !== null && attrResult.outcome !== 'complete')) {
process.exitCode = 1
}
} catch (err) {
Expand Down
Loading
Loading