Skip to content

fix(hooks): classify company-credential Claude sessions as team#4259

Merged
daviddanialy merged 2 commits into
mainfrom
daviddanialy/account-type-company-credentials
Jul 16, 2026
Merged

fix(hooks): classify company-credential Claude sessions as team#4259
daviddanialy merged 2 commits into
mainfrom
daviddanialy/account-type-company-credentials

Conversation

@daviddanialy

@daviddanialy daviddanialy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

On the Costs page's account-type breakdown, orgs whose engineers run Claude Code against company credentials (API key, gateway/proxy, Bedrock, Vertex) see nearly all spend under "(unset)" — e.g. SmartNews: 98.3% / $63k unset, with 273 work-email users unclassified.

Root cause: only a personal Claude subscription (OAuth login) emits user.account_uuid; company-credential auth never does. attributeSession early-returned on the empty UUID before classifying, so account_type was never stamped.

Verified in prod (30d, session-level, native claude-code OTEL):

SmartNews MoonPay
Sessions without UUID, work email 12,649 (272 users) 0
Sessions with UUID, work email 82 (6 users) 38,465 (198 users)

Same client versions show 0–5% UUID rows at SmartNews vs 99.4–100% at MoonPay, ruling out a client-version artifact — the discriminator is auth mode.

Fix

Absence of user.account_uuid means no personal account is behind the session, so it is company credentials — classify team:

  • classifyAccount: empty ExternalAccountUUIDteam (with a documented residual gap for first-batch UUID timing and pre-UUID clients).
  • attributeSession: always classifies and stamps AccountType; the user_accounts entity, device bridge, and billing mode — all keyed on the absent, NOT NULL UUID — are still skipped.
  • OTEL fast path: keys on the resolved AccountType for UUID-less sessions (they never get a UserAccountID), while UUID-bearing sessions still require a persisted UserAccountID so a transiently failed entity upsert retries on the next batch.
  • sessionEnrichesAttribution: a late-arriving user.account_uuid (mid-session OAuth login) re-attributes and persists the real account entity.

No Postgres row is created for these sessions (user_accounts.external_account_uuid is the entity key); the stamped ClickHouse account_type is what the cost surfaces consume.

Scope

Applies at ingest (not retroactive) and covers Claude OTEL-emitting sessions. Codex/Cursor set account_type="" and never run attribution — their spend stays unset (separate feature).

Testing

  • New TestLogs_ClassifiesCompanyCredentialSessionAsTeam: UUID-less session → team stamped on telemetry, no user_accounts row.
  • Full hooks attribution/OTEL suites pass; mise build:server + mise lint:server clean.

🤖 Generated with Claude Code


Summary by cubic

Classifies Claude sessions using company credentials (API key, gateway/proxy, Bedrock, Vertex) as team when user.account_uuid is absent, preventing spend from landing in the "(unset)" bucket on the Costs page. Attribution now always stamps account_type and, for UUID-less sessions, participates in the device bridge while skipping UUID-keyed persistence.

  • Bug Fixes
    • classifyAccount: empty ExternalAccountUUIDteam.
    • attributeSession: always stamps AccountType; teaches/resolves the device bridge by DeviceID even without a UUID; if UUID is missing, skip user_accounts upsert and billing mode.
    • OTEL fast path: caches UUID-less sessions by resolved AccountType; UUID-bearing sessions still require a persisted UserAccountID to retry on failure.
    • Late user.account_uuid re-attributes and persists the correct account mid-session.
    • Scope: ingest-only; affects Claude OTEL-emitting sessions; Codex/Cursor remain unchanged.

Written for commit 50228f6. Summary will update on new commits.

Review in cubic

Claude sessions authenticated by company credentials (API key, gateway/
proxy, Bedrock, Vertex) emit no user.account_uuid, so account attribution
no-op'd and their entire spend fell into the "(unset)" account-type bucket
on the Costs page. A personal Claude subscription always signs in via
OAuth and always emits the UUID, so its absence means company credentials:
classify these sessions team. Attribution now always classifies and stamps
account_type; the user_accounts entity, device bridge, and billing mode —
all keyed on the absent UUID — are still skipped, and the OTEL fast path
keys on the resolved AccountType for these sessions (a UUID-bearing
session still requires a persisted UserAccountID so transiently failed
entity persistence retries on the next batch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@daviddanialy
daviddanialy requested a review from a team as a code owner July 16, 2026 04:12
@daviddanialy daviddanialy added bug Something isn't working go Pull requests that update go code labels Jul 16, 2026
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 50228f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread server/internal/hooks/account_attribution.go Outdated
The device bridge keys on the per-device id, not the account UUID, so a
UUID-less gateway session with a resolved work email must still teach
device -> employee: at a gateway-only org it is the only session shape
that ever can, and without it a personal account later seen on the same
device could never be attributed to its employee. Only the user_accounts
entity and billing mode key on the UUID and remain skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@daviddanialy daviddanialy added the review:bypass Merge without human review approval. Required status checks still apply. label Jul 16, 2026

@gram-bot gram-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Auto-approved: review:bypass label applied by @daviddanialy. Required status checks still gate this merge.

@daviddanialy
daviddanialy added this pull request to the merge queue Jul 16, 2026
Merged via the queue into main with commit b6f3467 Jul 16, 2026
41 checks passed
@daviddanialy
daviddanialy deleted the daviddanialy/account-type-company-credentials branch July 16, 2026 04:45
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

bug Something isn't working go Pull requests that update go code review:bypass Merge without human review approval. Required status checks still apply.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant