Skip to content
Closed
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 @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `GET /api/v1/history` returns `heatmap_covered_since`. Both heatmaps resolve hour of day, which `daily_usage` does not keep, so they stay raw-only however far back the charts above them now reach. The History page states that window under each one instead of drawing empty cells that read as "no activity" for days that were merely rolled up

### Fixed
- A dated model id resolves to a price. Claude Code sends Haiku 4.5 as `claude-haiku-4-5-20251001`, but the price table is keyed undated and the lookup only stripped the tier suffix (`[1m]`), so every Haiku span was stored with `cost_usd` left NULL behind a `pricing: unknown model` warning — ingest keeps the column unset rather than writing a zero, so the spans were absent from spend totals rather than counted as free. The trailing `-YYYYMMDD` is now stripped after the tier suffix — a real id can carry both — and only when the tail is exactly eight digits, so a version segment (`claude-haiku-4-5`) is not mistaken for a date. The table is unchanged: undated keys stay the one canonical form
- `GET /api/v1/overview`'s `users_count` obeyed neither the time window nor the `user_id` filter — it was a bare `SELECT COUNT(DISTINCT user_id) FROM spans`, so the "Users" KPI answered all-time and unscoped next to four KPIs that did not. It now counts the distinct principals active in the selected range, and counts unattributed spans as the single `__anonymous__` principal the users list shows rather than as zero
- The Overview's `total_cost_usd` and token KPIs no longer drop spans that carry no `session_id`. They were computed under the same `session_id IS NOT NULL` clause as the session count, so the page's cost total could sit below the Costs page's for the same window
- A bare `WHERE col = <constant>` on `spans` returns the matching rows again instead of silently returning none. `duration_ms` was a VIRTUAL generated column declared mid-table: it took a logical slot but no storage slot, so every column after it had a logical index one ahead of its physical index, and an equality on such a column probed an unrelated ART index and found nothing — a wrong result, not an error. Schema version 10 drops the column and computes the duration at the four queries that read it, so logical and physical indexes line up and the trap is gone rather than worked around; the engine-fragile `COALESCE(tool_name, '') = 'Bash'` workaround (DuckDB 1.4+ pushes `COALESCE` down too) goes with it. Verified against a copy of production (108 MB, 34 706 spans): `tool_name = 'Bash'` counted 0 rows before the migration and 6 440 after, `service_name = 'claude-code'` 0 before and 34 705 after, with the span count unchanged ([ADR-0013](docs/decisions/0013-spans-has-no-derived-columns.md))
Expand Down
29 changes: 29 additions & 0 deletions internal/ingest/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,35 @@ func TestProducerCostUSDUsedWhenPresent(t *testing.T) {
}
}

// TestCostComputedForDatedModelID verifies the shape Claude Code actually sends
// for Haiku 4.5 — a dated snapshot id — resolves to a price and not to a
// "pricing: unknown model" warning with cost_usd left nil.
func TestCostComputedForDatedModelID(t *testing.T) {
// claude-haiku-4-5: $1/MTok in → 1M tokens = $1.00
attrs := `{"key":"model","value":{"stringValue":"claude-haiku-4-5-20251001"}},` +
`{"key":"input_tokens","value":{"intValue":"1000000"}}`
store := &memStore{}
h := ingest.New(store)

req := httptest.NewRequest(http.MethodPost, "/v1/traces",
bytes.NewBufferString(buildMinimalPayload(attrs)))
req.Header.Set("Content-Type", "application/json")
h.ServeHTTP(httptest.NewRecorder(), req)
h.Flush()

if len(store.spans) != 1 {
t.Fatalf("expected 1 span, got %d", len(store.spans))
}
s := store.spans[0]
if s.CostUSD == nil {
t.Fatal("dated model id must resolve to a price, got nil cost_usd")
}
const want = 1.00 // 1M * $1/MTok
if got := *s.CostUSD; got < want-0.001 || got > want+0.001 {
t.Errorf("computed cost: got %.6f, want %.6f", got, want)
}
}

// TestUnknownModelCostIsNil verifies that an unrecognised model id does not
// cause an ingest failure — cost_usd stays nil so it is distinguishable from
// a genuine zero-cost span.
Expand Down
27 changes: 23 additions & 4 deletions internal/pricing/prices.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ type ModelPrices struct {
}

// table maps canonical model IDs to pricing. Keys must be lowercase and match
// what Claude Code emits in the "model" span attribute (without tier suffixes).
// what Claude Code emits in the "model" span attribute, minus the tier and dated
// snapshot suffixes canonicalID strips.
//
// Cache rates follow Anthropic's convention: CacheRead = 0.1 x input,
// CacheWrite = 1.25 x input (the 5-minute ephemeral TTL).
Expand Down Expand Up @@ -103,15 +104,33 @@ var table = map[string]ModelPrices{
},
}

// canonicalID strips tier suffixes like "[1m]" from model identifiers before lookup.
// Claude Code may append these for extended-context variants that share base pricing.
// canonicalID reduces a model identifier to a table key by stripping a tier
// suffix like "[1m]" and then a dated snapshot suffix like "-20251001". Claude
// Code emits either, or both at once, for variants that share base pricing.
func canonicalID(model string) string {
if i := strings.IndexByte(model, '['); i != -1 {
return model[:i]
model = model[:i]
}
if i := strings.LastIndexByte(model, '-'); i > 0 && isSnapshotDate(model[i+1:]) {
model = model[:i]
}
return model
}

// isSnapshotDate reports whether s is exactly eight digits. The length check is
// what keeps a version segment ("claude-haiku-4-5") from being read as a date.
func isSnapshotDate(s string) bool {
if len(s) != 8 {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}

// Compute returns the estimated cost in USD for the given token counts and model.
// If the model is not in the price table it logs a warning and returns 0 so that
// an unknown model never causes an ingest failure.
Expand Down
57 changes: 57 additions & 0 deletions internal/pricing/prices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,63 @@ func TestComputeTierSuffix(t *testing.T) {
}
}

func TestComputeSnapshotSuffix(t *testing.T) {
// Claude Code sends Haiku 4.5 as a dated snapshot ID; the table is keyed
// undated, so the "-20251001" tail must be stripped before lookup.
dated := pricing.Compute("claude-haiku-4-5-20251001", 1_000_000, 500_000, 0, 0)
undated := pricing.Compute("claude-haiku-4-5", 1_000_000, 500_000, 0, 0)
if dated != undated {
t.Errorf("snapshot suffix not stripped: dated=%v undated=%v", dated, undated)
}
// 1M input @ $1 + 0.5M output @ $5 = 1 + 2.5 = 3.5
want := 3.5
if math.Abs(dated-want) > 0.000001 {
t.Errorf("got %.8f, want %.8f", dated, want)
}
}

func TestComputeSnapshotAndTierSuffix(t *testing.T) {
// Both tails at once — tier is stripped first, then the date.
both := pricing.Compute("claude-opus-5-20250930[1m]", 1_000_000, 500_000, 0, 0)
bare := pricing.Compute("claude-opus-5", 1_000_000, 500_000, 0, 0)
if both != bare {
t.Errorf("combined suffixes not stripped: both=%v bare=%v", both, bare)
}
if both <= 0 {
t.Errorf("claude-opus-5-20250930[1m] must have non-zero cost, got %v", both)
}
}

func TestComputeSnapshotStripIsExactlyEightDigits(t *testing.T) {
// The strip must not eat a version segment or a digit run of another length.
for _, model := range []string{
"claude-haiku-4-5",
"claude-3-5-haiku",
"claude-opus-5",
} {
if got := pricing.Compute(model, 1_000_000, 0, 0, 0); got <= 0 {
t.Errorf("%s: undated ID mangled, got %v", model, got)
}
}
for _, model := range []string{
"claude-haiku-4-5-2025100", // 7 digits
"claude-haiku-4-5-202510011", // 9 digits
"claude-haiku-4-5-2025100a", // not all digits
} {
if got := pricing.Compute(model, 1_000_000, 0, 0, 0); got != 0 {
t.Errorf("%s: expected no strip and cost 0, got %v", model, got)
}
}
}

func TestComputeUnknownDatedModel(t *testing.T) {
// A dated ID whose base is still unknown must return 0, not panic.
got := pricing.Compute("claude-unknown-99-20251001", 1_000_000, 500_000, 0, 0)
if got != 0 {
t.Errorf("unknown dated model: expected 0, got %v", got)
}
}

func TestComputeUnknownModel(t *testing.T) {
// Unknown model must return 0, not panic or error.
got := pricing.Compute("claude-unknown-99", 1_000_000, 500_000, 0, 0)
Expand Down