Skip to content

feat: codeburn sync — push usage telemetry to a team backend (preview)#660

Merged
iamtoruk merged 4 commits into
getagentseal:mainfrom
Enclavet:feat/sync
Jul 13, 2026
Merged

feat: codeburn sync — push usage telemetry to a team backend (preview)#660
iamtoruk merged 4 commits into
getagentseal:mainfrom
Enclavet:feat/sync

Conversation

@Enclavet

Copy link
Copy Markdown

Summary

Adds an opt-in codeburn sync command group that pushes AI usage metadata from a developer's machine to a team-operated OTLP-compatible backend, authenticated via standard OIDC browser login. This lets teams aggregate cost/usage dashboards across developers while keeping codeburn's local-first model: nothing is sent unless a developer explicitly runs sync setup and sync push.

Privacy is structural, not configurational: the OTLP payload builder has no field that can carry prompts, responses, file contents, or bash commands. Only per-call metadata leaves the machine — provider, model, token counts, cost, project name, tool names, and timestamps. There is deliberately no flag to include more.

Marked preview — the discovery/protocol may change between releases.

Design

Full design doc: #625

codeburn sync setup https://telemetry.example.com
        │
        ├─ GET /.well-known/codeburn-export.json   (discovery: issuer, client_id, traces_path, max_batch_size)
        ├─ OIDC discovery → browser login (Auth Code + PKCE, localhost callback :19876-78)
        └─ refresh token → OS keychain (macOS security / Linux libsecret / Windows DPAPI / 0600 file fallback)

codeburn sync push [--since 7d|30d|all] [--dry-run]
        │
        ├─ refresh access token
        ├─ parse local sessions (same pipeline as `codeburn report`)
        ├─ subtract client-side sent-ledger  →  only unsent calls
        ├─ batch by max_batch_size → POST OTLP/HTTP JSON with Bearer token
        └─ ledger successful batches (exact dedup, no watermark races)

Key decisions:

  • Provider-agnostic OIDC — works with Cognito, Auth0, Okta, Keycloak, etc. No client secret (public client + PKCE). offline_access scope negotiated from IdP metadata.
  • Single-URL setup — everything derives from one discovery document; no multi-flag configuration.
  • Client-side sent-ledger (~/.cache/codeburn/sync-ledger.json) — exact dedup keyed by the existing deduplicationKey. Chosen over timestamp watermarks (which silently skip late-arriving calls) and over server-side dedup (which requires a smarter backend).
  • Deterministic span IDsspan_id = SHA-256(deduplicationKey)[:8], trace_id = SHA-256(sessionId)[:16]. Every retry path is idempotent; servers that key by span ID treat re-sends as upserts.
  • Runs to completion — no routine per-push cap. Server rate limits are the brake: 429 Retry-After is honored (delta-seconds or HTTP-date, ≤120s per wait, 3 retries per batch) before deferring to the next push. 50K safety valve for pathological cases.
  • Partial success is batch-atomic — OTLP's partial_success doesn't identify which spans were rejected, so a partially-rejected batch is not ledgered and retries whole (safe via deterministic IDs).
  • Pseudonymous device IDSHA-256(hostname:username)[:16] as a resource attribute; developer identity comes from the JWT sub server-side.

What's in the PR

src/sync/discovery.ts Discovery doc fetch/validation (version-gated)
src/sync/auth.ts OIDC discovery, PKCE, localhost callback server, token exchange/refresh/revoke
src/sync/credentials.ts Keychain abstraction — all subprocess calls via execFileSync arg arrays; tokens flow via stdin (Linux) / env var (Windows), never shell-interpolated
src/sync/config.ts ~/.config/codeburn/sync.json
src/sync/ledger.ts Sent-ledger read/append/prune (6-month retention)
src/sync/otlp.ts ParsedApiCall[]ExportTraceServiceRequest JSON
src/sync/push.ts Orchestration with typed outcomes (complete / auth-rejected / rate-limited / server-error)
src/sync/cli.ts setup / push / status / logout / reset commands
docs/sync/README.md User guide
docs/sync/DEVELOPER.md Protocol, architecture, server contract, testing

No changes to existing code beyond two registration lines in src/main.ts and a README section. 22 files, +3,549 lines.

Testing

70 tests (all offline, CI-safe) + 5 developer-only integration tests:

  • sync.test.ts (26) — discovery parsing, PKCE, auth URL, scope negotiation, callback server (state mismatch, IdP error, timeout, port fallback), config round-trip
  • sync-ledger-otlp.test.ts (23) — deterministic ID derivation, OTLP structure/attributes, batching, ledger append/dedup/prune/clear
  • sync-push.test.ts (15) — full pipeline against a scriptable mock OTLP server: success + ledger + cost, partial-success (batch NOT ledgered), 401 stop, 429 wait-and-retry / persistent-429 give-up / Retry-After parsing (delta, HTTP-date, garbage), 5xx deferral, idempotent failure recovery
  • sync-e2e.test.ts (6) — full setup→refresh→rotate→logout round-trip against an in-process mock IdP
  • Developer-only (skip without env vars): headless-browser PKCE flow against a real IdP (1), real-backend integration (4)

Verified end-to-end against a deployed test backend (API Gateway JWT authorizer + Lambda + Cognito): initial 8,015-call backfill in one command (~16s), re-push correctly no-ops, invalid token → 401, logout revokes.

Backend requirements (documented in DEVELOPER.md)

Any endpoint that accepts OTLP/HTTP JSON behind an OIDC-validating gateway works — an OTEL Collector (ADOT, Alloy, otelcol) or a minimal custom ingest. The repo does not ship backend infra; the server contract is: serve the discovery doc, validate JWTs from the configured issuer, accept OTLP JSON, return standard OTLP responses.

Compatibility

  • No new runtime dependencies (Node built-ins: crypto, http, child_process)
  • No behavior change for users who never run codeburn sync
  • Cross-platform: macOS / Linux / Windows credential storage with graceful file fallback

Open questions for maintainers

  1. Should the preview flag gate behind an env var (CODEBURN_SYNC=1) or is the docs label enough?
  2. Ledger location: ~/.cache/codeburn/ vs alongside config — preference?

andklee added 3 commits July 12, 2026 16:05
Adds an opt-in `codeburn sync` command group: setup, push, status,
logout, reset. Developers authenticate via standard OIDC browser login
(Authorization Code + PKCE, provider-agnostic — Cognito, Auth0, Okta,
Keycloak, etc.) and push per-call usage metadata to a team-operated
OTLP/HTTP endpoint.

Privacy is structural: the payload builder has no field that can carry
prompts, responses, file contents, or bash commands. Only metadata
leaves the machine (provider, model, tokens, cost, project, tool names,
timestamps). There is deliberately no flag to include more.

Setup derives everything from a single URL:
  GET {base}/.well-known/codeburn-export.json
  → issuer, client_id, traces_path, max_batch_size

Modules:
- discovery.ts   discovery doc fetch/validation (version-gated)
- auth.ts        OIDC discovery, PKCE, localhost callback (:19876-78),
                 token exchange/refresh/revoke
- credentials.ts refresh token in OS keychain (macOS security / Linux
                 libsecret / Windows DPAPI / 0600 file fallback). All
                 subprocess calls use execFileSync arg arrays; tokens
                 flow via stdin or env var — never shell-interpolated
- config.ts      ~/.config/codeburn/sync.json
- ledger.ts      client-side sent-ledger for exact dedup (no watermark
                 races), 6-month prune
- otlp.ts        ParsedApiCall[] → ExportTraceServiceRequest JSON with
                 deterministic IDs: span=SHA256(dedupKey)[:8],
                 trace=SHA256(sessionId)[:16] — every retry idempotent
- push.ts        orchestration with typed outcomes; pushes run to
                 completion — 429 Retry-After honored (≤120s/wait,
                 3 retries/batch) before deferring; partial_success is
                 batch-atomic (nothing ledgered, whole batch retries);
                 50K safety valve, no routine cap
- cli.ts         command layer; --dry-run reports exact counts/cost
                 without sending

Preview feature: protocol may change between releases.

AI-Origin: human
Offline, CI-safe (70 tests):
- sync.test.ts (26): discovery parsing, PKCE, auth URL, scope
  negotiation, callback server (state mismatch, IdP error, timeout,
  port fallback), config round-trip
- sync-ledger-otlp.test.ts (23): deterministic ID derivation, OTLP
  structure/attributes, batching, ledger append/dedup/prune/clear
- sync-push.test.ts (15): pipeline against a scriptable mock OTLP
  server — success+ledger+cost, partial-success batch NOT ledgered,
  401 stop, 429 wait-and-retry / persistent-429 give-up / Retry-After
  parsing (delta-seconds, HTTP-date, garbage), 5xx deferral,
  idempotent failure recovery
- sync-e2e.test.ts (6) + fixtures/mock-idp.ts: full setup→refresh→
  rotate→logout round-trip against an in-process mock IdP

Developer-only (skip unless env vars set, never CI):
- sync-headless-e2e.test.ts (1): real browser PKCE flow via Playwright
- sync-infra-e2e.test.ts (4): push/auth-reject/batch/idempotent
  re-push against a deployed backend

AI-Origin: human
- docs/sync/README.md: setup, push, status, logout, reset; privacy
  guarantees; FAQ
- docs/sync/DEVELOPER.md: architecture, discovery/OTLP protocol,
  sent-ledger design rationale, partial-success and rate-limiting
  semantics, server contract, testing guide
- README.md: sync command group listed under Commands (preview label)

AI-Origin: human
@Enclavet Enclavet changed the title # feat: codeburn sync — push usage telemetry to a team backend (preview) feat: codeburn sync — push usage telemetry to a team backend (preview) Jul 12, 2026
@iamtoruk

Copy link
Copy Markdown
Member

Reviewed the full diff at cec61e3, plus a local run of the offline suite on macOS. Short version: this is a solid implementation and it holds up against what we locked in #625. All twelve amendments are implemented as agreed; I verified each in code rather than from the description. Two-hop discovery (codeburn-export.json first, then OIDC discovery against the issuer), fixed loopback ports with the 127.0.0.1 literal, scope negotiation from scopes_supported, rotation-safe refresh handling, no keytar, the sent-ledger with deterministic IDs, batch-atomic partial-success handling, strict OTLP/HTTP JSON, sub-based identity plus codeburn.device_id, manual push only, and no message export. The privacy claim is structural as promised: buildOtlpPayload maps a fixed allow-list of ParsedApiCall fields, and neither userMessage nor bashCommands is reachable from it. The failure-mode handling in push.ts and the honest storage fallbacks in credentials.ts are exactly what we asked for.

Three items to address before merge, then smaller notes.

Before merge

1. Enforce https on every remote endpoint. fetchDiscoveryDoc (src/sync/discovery.ts:65) and fetchOidcConfig (src/sync/auth.ts:34) accept any scheme, and the token endpoint from OIDC discovery is used as-is (auth.ts:219, auth.ts:255). The one https check in the flow (src/sync/cli.ts:81) only guards the browser open, and its failure is swallowed at cli.ts:93, so setup continues with the http URL printed for the user to click. Refresh tokens and bearer tokens travel on these endpoints. Please reject non-https URLs for baseUrl, issuer, authorization_endpoint, token_endpoint, and the traces endpoint, with an exception for http://127.0.0.1 so the offline tests keep working. RFC 8252 section 8.3 is the reference.

2. Keep the e2e tests off the real macOS keychain. The HOME stubbing in tests/sync-e2e.test.ts isolates the config and the file store, and it works on Linux. On darwin, createCredentialStore() returns KeychainStore unconditionally, which ignores HOME and talks to the real login keychain under the fixed service/account names. I ran the offline suite locally on a Mac: 65/70 pass, and the 5 failures are exactly the tests that call store.store() (keychain access was denied in my environment; on a permissive machine they would write and delete a real codeburn-sync entry instead, and the first two tests have no cleanup). CI stays green because Linux runners take the file-store path. Suggest an injection point (env var or a store parameter) so tests always use a temp-dir file store. That also removes the GUI prompt risk for anyone running the suite on a Mac.

3. Pin golden values for the deterministic IDs. The encoding tests pin the timestamp and attribute mapping, which is good. deriveSpanId, deriveTraceId, and getDeviceId are only asserted as shape and same-process determinism (tests/sync-ledger-otlp.test.ts:62-105). The idempotency design depends on these being stable across releases: if the hash input ever changes, every re-send gets a new identity and any backend keying on span ID double-counts history, with all tests green. Three assertions with fixed inputs and expected hex outputs close this.

Smaller items (fine as fast follows)

  1. Callback port race: startCallbackServer returns port: 0 (auth.ts:199) and the CLI compensates with a 100ms sleep plus server.address() (cli.ts:59). If binding is slow after a port fallback, the auth URL and the bound port can diverge, ending in redirect_mismatch. Resolving the bound port from the listening event removes the sleep.
  2. fetchOidcConfig does not verify that the issuer claim in the response matches the URL it was fetched from (OIDC Discovery 4.3). One comparison; it is the standard mix-up defense.
  3. partialSuccess.rejectedSpans arrives as a string from strict protojson servers (proto3 int64 JSON mapping). The > 0 withhold logic still behaves correctly, but totalRejected += rejected concatenates (push.ts:146-153) and the summary prints a wrong count. Wrap in Number().
  4. Ledger writes are a direct writeFileSync (ledger.ts:38-41). A crash mid-write corrupts the file, readLedger recovers to an empty array, and the next push re-sends the whole window, which double-counts on backends without span-ID dedup. Temp file plus rename, and a corrupt-ledger test, would make this solid.
  5. The mock IdP token endpoint does not validate code_verifier against the challenge, and the e2e simulates the browser with a fabricated code, so the PKCE binding is never exercised end to end. Adding the S256 check to the mock makes the e2e meaningful on this axis.
  6. macOS keychain writes put the token in argv briefly (credentials.ts:37); the in-code comment is honest about the tradeoff. security -i with the command on stdin avoids it, if you want to close that as well.
  7. ai.cost_estimated is derived from provider === 'kiro' || inputTokens === 0 (otlp.ts:105). Fine for preview; long term this wants a real flag on ParsedApiCall so it does not rot as providers change.
  8. sync reset is a fifth subcommand beyond the four we scoped. Happy to keep it, and the --confirm gate is right. Please have it call clearLedger() instead of reimplementing the path and unlink (cli.ts:196-206).
  9. push exits 0 on rate-limited and server-error outcomes. For cron and script use, a non-zero exit on incomplete pushes would be more predictable. Your call whether that lands here or later.

Your two open questions

  • Preview gating: the docs label is enough. The feature is opt-in by construction (nothing happens without sync setup), and an env var gate would add support burden without adding safety. Keep "preview" in the command description and release notes.
  • Ledger location: ~/.cache/codeburn/ is correct; the ledger is reconstructible state, not config. Worth honoring XDG_CACHE_HOME when set, since the rest of the ecosystem does.

One note for other reviewers: playwright lands as a devDependency for the skip-by-default headless test; no runtime dependency change (verified in the lockfile).

Good work on this. With items 1 to 3 addressed, this is ready.

…tion, golden ID pins

Before-merge items:
1. HTTPS enforced on every remote endpoint (RFC 8252 §8.3): baseUrl,
   issuer, authorization/token/revocation endpoints, and the traces
   endpoint all reject non-https, with a loopback (127.0.0.1/::1/
   localhost) exception for offline tests and local dev. Enforcement is
   central (assertHttps) — the browser-open guard is no longer the only
   check whose failure was swallowed.
2. Credential store test isolation: CODEBURN_SYNC_TOKEN_STORE=file
   forces the file store (honors HOME) so the offline suite never
   touches the real macOS login keychain. Set in the e2e suite.
3. Golden pins for deriveSpanId/deriveTraceId/deriveDeviceId with fixed
   inputs and expected hex — the idempotency contract depends on these
   encodings being stable across releases; a green-tests encoding change
   would silently double-count history on span-ID-keyed backends.
   getDeviceId refactored over a pure deriveDeviceId(host, user).

Smaller review items:
- Callback server: ready promise resolves the actually-bound port from
  the listening event (kills the 100ms-sleep race after port fallback);
  Connection: close on all responses + closeAllConnections() on
  shutdown (pooled keep-alive sockets from a closed server could
  swallow requests aimed at a later server on the same port); error
  handler guarded so a post-bind error can never rebind to a different
  port than advertised; optional ports param ([0] = ephemeral) removes
  fixed-port contention between parallel test workers.
- fetchOidcConfig verifies the issuer claim matches the fetch origin
  (OIDC Discovery §4.3 mix-up defense).
- partialSuccess.rejectedSpans wrapped in Number() — proto3 int64 JSON
  mapping sends strings from strict protojson servers; += would
  concatenate.
- Ledger writes are atomic (temp + rename); corrupt-ledger recovery and
  no-tmp-left-behind tests added; XDG_CACHE_HOME honored (ledger is
  reconstructible state, not config).
- Mock IdP now implements /oauth2/authorize (registers PKCE challenge,
  302s to redirect_uri) and verifies S256 code_verifier + single-use
  codes at the token endpoint. The e2e drives the real redirect flow
  and asserts wrong-verifier and code-reuse are rejected — PKCE binding
  is now exercised end to end.
- sync reset calls clearLedger() instead of reimplementing the path.
- push sets exit code 1 on rate-limited/server-error outcomes so cron
  and script callers can detect incomplete pushes.

Deferred (noted for fast-follow): macOS 'security -i' stdin mode
(untestable on this Linux box), ai.cost_estimated as a real
ParsedApiCall flag (touches core parser types).

Sync suite: 81 passing (5x stable), 5 developer-only.

AI-Origin: human
@Enclavet

Copy link
Copy Markdown
Author

Added a commit to address review comments. The blockers and fast follows were addressed except the following:

  • macOS security -i stdin mode. Agreed it closes the argv-visibility window. I'm developing on Linux and can't test security -i escaping — and getting the quoting wrong inside security's own command parser would reintroduce a variant of the injection this PR just eliminated. The in-code comment documents the tradeoff; the fix needs strict token charset validation and a real Mac to verify. If you have cycles on your Mac, I'd take that as a follow-up PR gladly.
  • ai.cost_estimated as a real flag. Agreed the provider === 'kiro' heuristic belongs in the provider parsers, not the export layer. The clean fix adds a field to ParsedApiCall set at parse time, which touches the core type, every provider, and needs a PROVIDER_PARSE_VERSIONS bump for cache invalidation — and the field name/shape deserves your input since it becomes part of the public type. Proposing usageSource: 'reported' | 'estimated' in a standalone follow-up PR.

@iamtoruk iamtoruk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified d8a7b2a against each review item, in code:

  • https enforcement is central (assertHttps) and covers baseUrl, issuer, authorization/token/revocation endpoints, and the traces endpoint, with the loopback exception.
  • Ran the offline suite on macOS: 81/81 pass, and the credential-store isolation keeps the real keychain untouched.
  • Recomputed the three golden hashes independently; all three pins are correct.
  • Also confirmed: bound-port ready promise, issuer claim check, Number() on rejectedSpans, atomic ledger writes plus XDG_CACHE_HOME, PKCE verification in the mock IdP with wrong-verifier and code-reuse tests, clearLedger() reuse, and exit code 1 on incomplete pushes.

Both deferrals are agreed:

  • security -i: keep it as a follow-up. Strict charset validation on the token before it goes anywhere near security's own parser is the right shape, and I can verify the escaping on a Mac when that PR comes.
  • usageSource: 'reported' | 'estimated' on ParsedApiCall works for me as the field name and shape. Setting it at parse time with a PROVIDER_PARSE_VERSIONS bump is the right layering. Ping me on that PR and I will review.

One non-blocking note for the follow-up pile: when all callback ports are in use, both ready and the callback promise reject, and the unawaited one surfaces as an unhandled rejection trace instead of the clean AuthError message. Cosmetic.

Merging. Thanks for the fast, thorough turnaround on this.

@iamtoruk iamtoruk merged commit 67f0df0 into getagentseal:main Jul 13, 2026
3 checks passed
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.

3 participants