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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `GET /api/v1/overview`, `/sessions`, `/costs` and `/models` accept `range`, the same five-key rolling window `/users` and `/tools` already took, and each echoes back the key it used. Defaults preserve today's behaviour instead of converging on one value: `/overview` and `/costs` default to `month` (the 30-day window they already applied), `/sessions` and `/models` to `all`, because they had no time filter and a `month` default would silently truncate every existing caller. Long ranges resolve against the `spans` ∪ `daily_usage` union at the raw-floor split, so `year` and `all` keep answering after retention has deleted the raw spans rather than repeating the `month` figure. On `/costs`, explicit `from`/`to` still beat the range key and the response then echoes `"range": null` ([ADR-0014](docs/decisions/0014-overview-single-range-selector.md))
- `GET /api/v1/sessions` returns `covered_since`. A session row needs a start time, model and status, none of which the roll-up keeps, so the list is raw-only; a range reaching past the raw floor is clamped and the field names the instant the list actually starts from (`null` when the range is fully covered). The Overview's Sessions block states that window in one line. The session *count* KPI is unaffected — `daily_usage` carries `session_id`, so counting distinct sessions across the union is exact

### Fixed
- `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))
- A failing `GET /api/v1/bash-commands` no longer renders as the "no command detail in this data" explainer. The Bash section branched on row count alone, so a request that errored looked identical to one that legitimately returned nothing, blaming Claude Code's telemetry for what was actually a server fault. Fetch failures now show the error

### Changed
- The Overview is one window instead of five. A single range switcher in the header — its own `cotel_overview_range` cookie, so it does not move the Users or Tools page — scopes every figure on the page. Previously the KPIs showed 30 days, the Sessions and Models blocks showed all time, and only the KPI labels said which, as a literal `(30d)` baked into the string; a reader comparing the Sessions KPI against the Models table below it was comparing 30 days against all time. Labels now take their suffix from the selected range, and `All` renders none
- Overview section order is Users, History, Costs, Tools, Models, Sessions. A new Users block leads with the top 5 principals by spend in the selected range, and Sessions moves to the bottom as the one block that cannot honour a long range. The Costs block drops its inner by-model table — the Models block below it is the same data at full width
- The Overview's user-search typeahead is gone, and the `UserSearch` component with it. Scoping is reached from a user's page ("View activity"); `?user_id=` now shows a chip in the header naming the user and clearing the scope on click, instead of a page that was silently filtered with nothing on it to say so
- A deploy now fails when the container does not come up. The Deploy workflow ended at `docker compose up -d`, which returns once the container has *started*, not once it works — so the last thing it observed of a deploy was `Up Less than a second (health: starting)` and it went green on that, reporting a container whose `storage.Open` had died identically to one serving traffic. It now runs `scripts/wait-for-healthy.sh`, which blocks on the container's own `HEALTHCHECK` and fails the job on `unhealthy`, on an exit, on a crash loop (in under a second, rather than waiting out the timeout — only restarts seen *during* the wait indict a deploy, since `up -d` leaves an already-current container in place and one that crashed once and recovered carries a restart count for the rest of its life, including while it legitimately replays a WAL), on a service that defines no healthcheck at all, or on a 120 s timeout — dumping `docker compose ps`, the last health-probe output and the container logs so the reason is in the run log instead of on the runner. `workflow_dispatch` takes a `health_timeout` input for the one deploy that legitimately needs longer: a start following a hard kill replays the WAL. The CI smoke job runs the same script in place of its `curl`-until-ready loop, so a break in the gate surfaces on a PR rather than on a deploy. Measured against a 109 MB copy of production, healthy at 6 s from cold with a 3.9 MB WAL to replay (the open itself 2.8 s, including the v10 migration) and 6 s on a redeploy of the warm database — the 6 s is the probe cadence, not the database
- The image `HEALTHCHECK` gains `--start-interval=5s`. `--interval=30s` also governed the probes during `start-period`, so a container that was ready in two seconds still reported `starting` for thirty, and the deploy gate above would have waited out all of it
- Schema version 10 removes `spans.duration_ms`. The migration moves no row data: it drops the four secondary indexes, drops the column (DuckDB refuses to `ALTER` a table an index depends on), and the existing `CREATE INDEX IF NOT EXISTS` block rebuilds them. On the 108 MB production copy the whole upgrade added 0.4 s to a cold start already dominated by WAL replay (9.5 s → 9.8 s), and a later re-apply of `schema.sql` costs 138 ms. No downgrade path: an older binary still starts against a v10 database (`CREATE TABLE IF NOT EXISTS` cannot bring the column back) but every query naming `duration_ms` then errors, so a rollback needs the pre-upgrade database too. Exported CSVs are unaffected — the `duration_ms` column of `spans.csv` was already derived in Go, so the format version does not move
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on

## What you get

- **Overview dashboard** — KPI cards for sessions, unique users, total cost, and token counts (30-day window), with per-user filter across all charts
- **Overview dashboard** — one range switcher in the header (All / Year / Month / Week / Day, default 30 days) that every figure on the page obeys: the KPI cards for sessions, users, total cost and token counts, and the Users / History / Costs / Tools / Models / Sessions blocks below them. The Users block ranks your top 5 principals by spend in the selected range. Arriving with `?user_id=` scopes the whole page to one user, with a chip in the header to clear it
- **Sessions** — live table of every Claude Code session with user, model, duration, cost, and status (OK / ERROR); search by user and click any user to filter the table to their sessions
- **History** — time-series and daily-activity heatmaps for sessions and token spend over time
- **Costs** — cumulative spend chart + breakdown table by model
Expand Down
119 changes: 119 additions & 0 deletions docs/decisions/0014-overview-single-range-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# ADR 0014 — Overview: one range selector every panel obeys

**Date:** 2026-08-20
**Status:** Accepted
**Deciders:** Daedalus (CTO)

---

## Context

The Overview page is the dashboard's front door: five KPIs and a stack of
sections that each summarise one resource and link to its full page. Today those
panels do not agree on what "now" means.

| Panel | Endpoint | Window it actually shows |
|---|---|---|
| KPIs | `GET /overview` | last 30 days, hardcoded server-side |
| Sessions | `GET /sessions` | all time — no time filter exists |
| History | `GET /history` | last 30 days, from/to computed in the page |
| Costs | `GET /costs` | last 30 days, from/to defaulted server-side |
| Tools | `GET /tools` | `range=all`, passed by the page |
| Models | `GET /models` | all time — no time filter exists |

Three different windows on one screen, and only the KPI labels say which — as a
literal `(30d)` baked into the string. A reader comparing the Sessions count KPI
against the Models table below it is comparing 30 days against all time, with
nothing on the page to say so.

`range` already exists as a contract. ADR-0011 introduced it for the Users list
and ADR-0012 extended it to Tools: five rolling-window keys
(`all|year|month|week|day`), `month` the default, unrecognised values falling
back rather than 400ing, and the window answered from a **union of raw `spans`
and rolled-up `daily_usage`** split at the earliest surviving raw day. That union
is not decoration. The retention worker (ADR-0009) rolls spans older than
`RawDays` (default 30) into `daily_usage` and deletes them, so any endpoint that
queries `spans` alone answers `year` and `all` with the same number it answers
`month` — a confident wrong total, which ADR-0011 rejected as worse than having
no switcher at all.

The board asked for a single range selector in the Overview header that every
figure on the page obeys. That request is unsatisfiable without deciding how far
the existing `range` contract reaches.

## Options considered

1. **Translate in the frontend only.** `/costs` and `/history` already take
`from`/`to`; the page could derive them from the selected range and leave the
API alone. Rejected on two counts. It cannot scope `/overview`, `/sessions`
or `/models` at all — they have no time parameter to translate into. And it
would answer long ranges from `spans` alone, reintroducing the exact defect
ADR-0011 exists to prevent.

2. **One fat `GET /overview?range=…` returning every panel.** One request, one
window, trivially consistent within the page. Rejected: it is a second
contract for numbers the per-resource endpoints already own, and each section
links to a full page served by those endpoints. Two independent
implementations of "cost in the last 30 days" will diverge, and the place it
shows up is a summary that disagrees with the page it links to.

3. **Extend the existing `range` contract to the remaining read endpoints
(chosen).** One parameter, one meaning, one union, everywhere the dashboard
reads. The Overview then holds no time logic of its own — it picks a key and
passes it down.

## Decision

`GET /api/v1/overview`, `/sessions`, `/costs` and `/models` accept `range` with
the same five keys, the same rolling-window semantics, and the same
fallback-don't-400 rule as `/users` and `/tools`. Each response echoes the
`range` it actually used.

**Defaults preserve today's behaviour rather than converging on one value.**
`/overview` and `/costs` default to `month`, which is the 30-day window they
already apply. `/sessions` and `/models` default to `all`, because they have no
time filter today and a `month` default would silently truncate every existing
caller. A page that wants a window asks for one; no caller's meaning changes
under it.

**Explicit bounds beat the range key.** `/costs` keeps `from`/`to`. When a
request carries both, `from`/`to` wins and `range` is ignored — the narrower,
more specific statement is the one the caller meant.

**Long ranges are answered from the union, not from `spans`.** Every metric that
recomposes from additive parts — cost, token totals, span and session counts,
distinct users, per-model and per-tool totals — sums across `spans` ∪
`daily_usage` at the raw-floor split defined in ADR-0011.

**A panel that cannot honour the range says so.** The Sessions *list* is the one
that cannot: `daily_usage` aggregates a session's day, cost and counts, but not
the start time, model and status a session row shows, so rows for rolled-up days
cannot be reconstructed. It stays raw-only, clamps the range to raw coverage,
and returns `covered_since` (RFC3339, `null` when the range is fully covered) so
the UI states the shortfall in one line. This is the rule ADR-0012 set for the
Bash breakdown: the constraint is displayed, not absorbed. The *session count*
KPI is unaffected — `daily_usage` carries `session_id`, so counting distinct
sessions across the union is exact.

The selected range persists in its own cookie, `cotel_overview_range`, per the
per-page rule in `useRangeCookie`: changing the range on Overview must not move
the Users or Tools page under a reader who switches tabs.

## Consequences

- The Overview stops being five windows stacked vertically. Every number on it
answers the same question, and the KPI labels carry the range suffix
(`RANGE_SUFFIX`) instead of a hardcoded `(30d)`.
- `/sessions` and `/models` gain a parameter and change no existing behaviour.
Callers that pass no `range` see exactly what they see today.
- `/sessions` grows `covered_since`, so a client can tell "no sessions in this
window" apart from "the window reaches past raw retention". It self-retires
the same way `duration_stats_since` does not: raw coverage is a standing
property of retention, so this field is permanent, not transitional.
- Six of the dashboard's read endpoints now share one time contract. The next
one is a parameter, not a design.
- `?user_id=` scoping on Overview is orthogonal and stays. It composes with
`range` on every endpoint above.
- The union costs a second scan over `daily_usage` on every Overview load. The
table is one row per (day, session, model, tool) and capped at
`AggregateDays` (90); the Users list already pays this on its default range.
1 change: 1 addition & 0 deletions docs/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially.
| [ADR-0011](./0011-users-list-ranged-stats-and-server-side-sort) | Users list — time-ranged stats, server-side sort and pagination | Accepted |
| [ADR-0012](./0012-tools-list-ranged-stats-and-server-side-sort) | Tools list — time-ranged stats, server-side sort and pagination | Accepted |
| [ADR-0013](./0013-spans-has-no-derived-columns) | `spans` carries no derived columns: drop `duration_ms` | Accepted |
| [ADR-0014](./0014-overview-single-range-selector) | Overview — one range selector every panel obeys | Accepted |
24 changes: 24 additions & 0 deletions docs/design/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ These are rendered by a shared `<PageHeader>` component that wraps every page's
| Region | Component | Field | Source | Format |
|---|---|---|---|---|
| KPI row | `<KpiCard>` | Sessions | `GET /overview → total_sessions` | Integer |
| KPI row | `<KpiCard>` | Active Users | `GET /overview → users_count` | Integer — principals with usage in the range, not the registered-user total the Users page lists |
| KPI row | `<KpiCard>` | Total Cost | `GET /overview → total_cost_usd` | `$0.00` |
| KPI row | `<KpiCard>` | Input Tokens | `GET /overview → total_input_tokens` | `1.2M`, `890K`, `12.4K` |
| KPI row | `<KpiCard>` | Output Tokens | `GET /overview → total_output_tokens` | Same |
Expand All @@ -116,6 +117,29 @@ These are rendered by a shared `<PageHeader>` component that wraps every page's
- Recent sessions table: non-sortable (fixed: most recent first), non-paginated, no filter bar.
- "View all sessions" link: right-aligned, `--text-sm`, `--color-accent`.

### Shipped section order

The page stacks six `<StatSection>` blocks, each a summary of one resource with a
"View all" link to its full page, in this order:

1. **Users** — top 5 by spend in the range. Hidden while the page is scoped to a
single user via `?user_id=`, where a top-5-users table would be the one panel
on the page not answering for that user.
2. **History** — activity area chart. `hour` granularity on the `Day` range,
`day` otherwise.
3. **Costs** — daily spend line. No inner by-model table: the Models block below
is the same data at full width.
4. **Tools** — top 5 by call count.
5. **Models** — all models by span count.
6. **Sessions** — 5 most recent. Last, because it is the only block that cannot
honour a long range (see `covered_since` in the API reference).

The header carries one `<SegmentedControl>` bound to `RANGE_OPTIONS` and
persisted in the `cotel_overview_range` cookie — its own key, so changing the
range here does not move the Users or Tools page. KPI labels take their suffix
from `RANGE_SUFFIX` (`All` renders none)
([ADR-0014](../decisions/0014-overview-single-range-selector.md)).

---

## 2. Sessions (`/sessions`)
Expand Down
Loading