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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Added
- `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
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)

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 by `(git.repo, git.sha)`.

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 shape-checked client-side (https, `/org/repo/pull/N` path, bounded length, max 20 per session) before sending. 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
78 changes: 67 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,41 @@ 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)
}
} 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) {
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''}\n`)
}
if (result.totalRejected > 0) {
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
}
Expand All @@ -337,7 +393,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
156 changes: 156 additions & 0 deletions src/sync/otlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { createHash } from 'crypto'
import { hostname, userInfo } from 'os'
import type { ParsedApiCall } from '../types.js'
import type { SessionAttributionRecord } from '../yield.js'

export interface OtlpSpan {
traceId: string
Expand Down Expand Up @@ -141,3 +142,158 @@ export function batchCalls(calls: CallWithSession[], maxBatchSize: number): Call
}
return batches
}

// --- Attribution spans (sync push --attribution) ---

export const SESSION_ATTRIBUTION_SPAN_NAME = 'codeburn.session.attribution'
export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit'

/**
* A single ledger-able attribution unit: either one session-level record
* (repo + PR links + commit count) or one attributed commit. The dedup key
* encodes the mutable state (inMain/wasReverted for commits; repo, PR links,
* and commit set for sessions), so a state TRANSITION mints a new key and the
* updated fact is re-sent on the next push — the receiver upserts by
* (repo, sha) / (session). Identical states dedupe via the sent-ledger.
*/
export type AttributionItem = {
kind: 'session' | 'commit'
dedupKey: string
/** Span start: commit author time for commits, session start for sessions. ISO 8601. */
timestamp: string
/** Span end for session items (session lastTimestamp). Absent for commits. */
endTimestamp?: string
sessionId: string
project: string
repo: string | null
// session kind
prLinks?: string[]
commitCount?: number
// commit kind
sha?: string
inMain?: boolean
wasReverted?: boolean
}

function stateHash(parts: string[]): string {
return createHash('sha256').update(parts.join('\u001e')).digest('hex').slice(0, 16)
}

/** Deterministic dedup key for a commit attribution fact (state included). */
export function commitAttributionKey(sessionId: string, sha: string, inMain: boolean, wasReverted: boolean): string {
return `attr:c:${sessionId}:${sha}:${inMain ? 1 : 0}${wasReverted ? 1 : 0}`
}

/** Deterministic dedup key for a session attribution fact (state included). */
export function sessionAttributionKey(record: SessionAttributionRecord): string {
const commitStates = record.commits
.map(c => `${c.sha}:${c.inMain ? 1 : 0}${c.wasReverted ? 1 : 0}`)
.sort()
return `attr:s:${record.sessionId}:${stateHash([record.repo ?? '', ...record.prLinks, ...commitStates])}`
}

/** Flatten attribution records into ledger-able items (one session item + one per commit). */
export function flattenAttributionRecords(records: SessionAttributionRecord[]): AttributionItem[] {
const items: AttributionItem[] = []
for (const record of records) {
items.push({
kind: 'session',
dedupKey: sessionAttributionKey(record),
timestamp: record.firstTimestamp,
endTimestamp: record.lastTimestamp,
sessionId: record.sessionId,
project: record.project,
repo: record.repo,
prLinks: record.prLinks,
commitCount: record.commits.length,
})
for (const commit of record.commits) {
items.push({
kind: 'commit',
dedupKey: commitAttributionKey(record.sessionId, commit.sha, commit.inMain, commit.wasReverted),
timestamp: commit.timestamp,
sessionId: record.sessionId,
project: record.project,
repo: record.repo,
sha: commit.sha,
inMain: commit.inMain,
wasReverted: commit.wasReverted,
})
}
}
return items
}

/**
* Build an OTLP payload from attribution items. Spans share the session's
* traceId with the usage spans (`deriveTraceId(sessionId)`), so a receiver
* can correlate cost and attribution without any extra key.
*/
export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPayload {
const deviceId = getDeviceId()

const spans: OtlpSpan[] = items.map(item => {
const startNano = toUnixNano(item.timestamp)
const endNano = item.endTimestamp
? toUnixNano(item.endTimestamp)
: (BigInt(startNano) + 1_000_000n).toString()

const attributes: OtlpAttribute[] = [
{ key: 'ai.session_id', value: { stringValue: item.sessionId } },
{ key: 'ai.project', value: { stringValue: item.project } },
]
if (item.repo) {
attributes.push({ key: 'git.repo', value: { stringValue: item.repo } })
}

if (item.kind === 'commit') {
attributes.push(
{ key: 'git.sha', value: { stringValue: item.sha ?? '' } },
{ key: 'git.in_main', value: { boolValue: item.inMain ?? false } },
{ key: 'git.was_reverted', value: { boolValue: item.wasReverted ?? false } },
)
} else {
attributes.push({ key: 'git.commit_count', value: { intValue: String(item.commitCount ?? 0) } })
if (item.prLinks && item.prLinks.length > 0) {
attributes.push({
key: 'git.pr_links',
value: { arrayValue: { values: item.prLinks.map(u => ({ stringValue: u })) } },
})
}
}

return {
traceId: deriveTraceId(item.sessionId),
spanId: deriveSpanId(item.dedupKey),
name: item.kind === 'commit' ? COMMIT_ATTRIBUTION_SPAN_NAME : SESSION_ATTRIBUTION_SPAN_NAME,
startTimeUnixNano: startNano,
endTimeUnixNano: endNano,
attributes,
}
})

return {
resourceSpans: [{
resource: {
attributes: [
{ key: 'codeburn.device_id', value: { stringValue: deviceId } },
// Honesty marker: this attribution is inferred (timestamp-window
// correlation), not declared. Receivers should label it as such.
{ key: 'codeburn.attribution_methodology', value: { stringValue: 'timestamp-window' } },
],
},
scopeSpans: [{
spans,
}],
}],
}
}

/** Split attribution items into batches of maxBatchSize. */
export function batchAttributionItems(items: AttributionItem[], maxBatchSize: number): AttributionItem[][] {
const batches: AttributionItem[][] = []
for (let i = 0; i < items.length; i += maxBatchSize) {
batches.push(items.slice(i, i + maxBatchSize))
}
return batches
}
Loading
Loading