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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- `GET /api/v1/history` accepts `range` too — the last read endpoint that did not. It defaults to `month`, the window `from`/`to` already defaulted to, so a caller that passes no range sees exactly what it saw before; explicit `from`/`to` still win and the response then echoes `"range": null`, the precedence `/costs` sets. At `day`, `week` and `month` granularity `buckets` and `by_model` resolve over the `spans` ∪ `daily_usage` union at the raw-floor split, so the History chart no longer stops a month back while the Costs panel beside it charts a year. `hour` stays raw-only and reports it in `covered_since`: the roll-up consumes whole UTC days and cannot produce a sub-day bucket, and day-shaped data under an hour label would be worse than a stated shortfall. `from`/`to` in the response echo the resolved bounds and are `null` on a side the window does not bound ([ADR-0014](docs/decisions/0014-overview-single-range-selector.md))
- `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
- `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
Expand All @@ -20,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 History block stops borrowing the sessions list's `covered_since` and reads the coverage the history response reports for its own series. The note it printed on `Year` and `All` is gone on every range the union now covers, and remains only where the chart is genuinely short — the `Day` range, which charts hourly. `RANGE_DAYS` goes with it: the page no longer derives `from`/`to` from the range key at all, so its copy of the window lengths cannot drift from the server's
- 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on

- **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
- **History** — time-series and daily-activity heatmaps for sessions and token spend over time. The day, week and month series keep charting after retention has rolled raw spans into daily totals; the hourly series and both heatmaps need a per-span timestamp, so they cover raw days only and say from when
- **Costs** — cumulative spend chart + breakdown table by model
- **Tools** — call counts, average duration, and error rate per tool (`Bash`, `Read`, `Edit`, …), scoped to a range switcher (All / Year / Month / Week / Day, default 30 days), plus a per-command breakdown of `Bash` calls
- **Models** — token and cost breakdown across all Claude model variants
Expand Down
74 changes: 62 additions & 12 deletions docs/operations/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ so a client can render its controls from the response alone.

## `range` on the summary endpoints

`GET /overview`, `GET /sessions`, `GET /costs` and `GET /models` accept the same
`range` parameter, with the same five keys, the same rolling-window semantics and
the same fall-back-don't-`400` rule
`GET /overview`, `GET /sessions`, `GET /costs`, `GET /models` and `GET /history`
accept the same `range` parameter, with the same five keys, the same
rolling-window semantics and the same fall-back-don't-`400` rule
([ADR-0014](../decisions/0014-overview-single-range-selector.md)). Each echoes
back the `range` it used. All four also accept `user_id`, which composes with
back the `range` it used. All five also accept `user_id`, which composes with
`range` rather than overriding it.

**Defaults preserve each endpoint's previous behaviour rather than converging on
Expand All @@ -50,6 +50,7 @@ one value:**
|---|---|---|
| `GET /overview` | `month` | The 30-day window it always applied |
| `GET /costs` | `month` | The 30-day window `from`/`to` already defaulted to |
| `GET /history` | `month` | Same — the window a bare request already answered |
| `GET /sessions` | `all` | Had no time filter; a `month` default would truncate existing callers |
| `GET /models` | `all` | Same |

Expand All @@ -61,12 +62,13 @@ instead of silently repeating the `month` figure.

Two consequences are worth knowing:

- **`GET /costs`: explicit bounds beat the range key.** When a request carries
`from` and/or `to`, those win and `range` is ignored — the narrower, more
specific statement is the one the caller meant. The response then echoes
`"range": null`. `top_sessions[].first_seen` degrades to the aggregate's day at
midnight UTC for rolled-up sessions, since `daily_usage` keeps no intra-day
timestamp; it is a lower bound on the real start, never a later one.
- **`GET /costs` and `GET /history`: explicit bounds beat the range key.** When a
request carries `from` and/or `to`, those win and `range` is ignored — the
narrower, more specific statement is the one the caller meant. The response
then echoes `"range": null`. On `/costs`, `top_sessions[].first_seen` degrades
to the aggregate's day at midnight UTC for rolled-up sessions, since
`daily_usage` keeps no intra-day timestamp; it is a lower bound on the real
start, never a later one.
- **`GET /sessions` clamps and says so.** A session row needs a start time, model
and status, none of which the roll-up carries, so the list is computed from raw
spans alone. A range reaching past the raw floor is clamped, and the response
Expand All @@ -79,8 +81,56 @@ Two consequences are worth knowing:
range, with all unattributed spans counting as the single `__anonymous__`
principal the users list shows.

`GET /history` is not part of this contract; it takes `from`/`to` and reads raw
spans only.
## `GET /history`

Activity over time, bucketed at the requested `granularity`.

| Param | Values | Default | Meaning |
|---|---|---|---|
| `granularity` | `hour` \| `day` \| `week` \| `month` | `day` | Bucket width for `buckets` and `by_model` |
| `range` | `all` \| `year` \| `month` \| `week` \| `day` | `month` | Rolling window, as above |
| `from`, `to` | `YYYY-MM-DD` | — | Explicit bounds; when either is present they win and `range` echoes `null` |
| `user_id` | user id \| `__anonymous__` | — | Scopes every figure to one principal |

```json
{
"granularity": "day",
"from": "2026-07-21",
"to": null,
"range": "month",
"buckets": [
{ "bucket": "2026-08-19", "sessions": 12, "spans": 940, "cost_usd": 8.21, "input_tokens": 1201, "output_tokens": 4402 }
],
"by_model": [
{ "bucket": "2026-08-19", "model": "claude-opus-5", "cost_usd": 7.90, "spans": 612 }
],
"heatmap": [
{ "date": "2026-08-19", "hour": 14, "count": 61, "cost_usd": 0.94 }
],
"covered_since": null,
"heatmap_covered_since": null
}
```

`from` and `to` echo the resolved bounds, and are `null` for a side the window
does not bound — `range=all` reports `"from": null`, and any range-scoped request
reports `"to": null` because the window runs to request time.

**Which parts span the roll-up.** At `day`, `week` and `month` granularity,
`buckets` and `by_model` are answered from the `spans` ∪ `daily_usage` union at
the raw-floor split, so `year` and `all` keep charting after retention has
deleted the raw spans. Two fields report where that stops:

- **`covered_since`** clamps `buckets` and `by_model`. It is always `null` at
`day`, `week` and `month`. At **`hour`** it names the raw floor when the window
reaches past it: `daily_usage` buckets whole UTC days and cannot produce a
sub-day bucket, so an hour series is raw-only rather than day-shaped data under
an hour label.
- **`heatmap_covered_since`** clamps `heatmap`, which resolves hour of day at
every granularity and is therefore always raw-only.

Both are RFC3339, and `null` when the selected window is fully covered by raw
spans.

## `GET /tools`

Expand Down
20 changes: 16 additions & 4 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,19 +373,31 @@ export interface HeatmapCell {

export interface HistoryResponse {
granularity: string
from: string
to: string
from: string | null
to: string | null
buckets: HistoryBucket[]
by_model: HistoryModelRow[]
heatmap: HeatmapCell[]
range: string | null
covered_since: string | null
heatmap_covered_since: string | null
}

export function useHistory(granularity: string, from?: string, to?: string, userId?: string) {
export function useHistory(
granularity: string,
from?: string,
to?: string,
userId?: string,
range?: string,
) {
const params = new URLSearchParams({ granularity })
if (from) params.set('from', from)
if (to) params.set('to', to)
if (userId) params.set('user_id', userId)
return useSWR<HistoryResponse>(`/api/v1/history?${params.toString()}`, fetcher)
if (range) params.set('range', range)
return useSWR<HistoryResponse>(`/api/v1/history?${params.toString()}`, fetcher, {
keepPreviousData: true,
})
}

export interface TokenItem {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/pages/History.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@
margin: 0 0 var(--space-2);
}

.coverageNote {
font-size: var(--text-sm);
color: var(--color-text-3);
margin: var(--space-3) 0 0;
}

/* ---- Calendar heatmap ---- */
.calWrap {
position: relative;
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/pages/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,19 @@ function tickLabel(gran: Granularity, bucket: string): string {
return bucket.slice(5)
}

// The daily roll-up keeps no intra-day timestamp, so anything that resolves an
// hour — the hourly series, and both heatmaps at any granularity — stays
// raw-only however far back the day, week and month series reach.
function CoverageNote({ since, subject }: { since: string | null; subject: string }) {
if (!since) return null
return (
<p className={styles.coverageNote}>
Plotted from {new Date(since).toLocaleDateString()} — {subject}, and earlier days in this
window survive only as whole-day totals.
</p>
)
}

export default function History() {
const [gran, setGran] = useState<Granularity>('day')
const [from, setFrom] = useState(daysAgo(30))
Expand Down Expand Up @@ -468,14 +481,18 @@ export default function History() {
</div>
</div>
)}
<CoverageNote since={data.covered_since} subject="hourly buckets are built from raw spans" />
</Card>

{/* Calendar heatmap */}
<Card title="Activity Calendar">
{calDays.length === 0 ? (
<EmptyState heading="No data in this range" />
) : (
<CalendarHeatmap days={calDays} from={data.from} to={data.to} />
<>
<CalendarHeatmap days={calDays} from={data.from ?? from} to={data.to ?? to} />
<CoverageNote since={data.heatmap_covered_since} subject="these cells resolve hour of day" />
</>
)}
</Card>

Expand All @@ -484,7 +501,10 @@ export default function History() {
{data.heatmap.length === 0 ? (
<EmptyState heading="No data in this range" />
) : (
<HourDowHeatmap heatmap={data.heatmap} />
<>
<HourDowHeatmap heatmap={data.heatmap} />
<CoverageNote since={data.heatmap_covered_since} subject="these cells resolve hour of day" />
</>
)}
</Card>

Expand Down
44 changes: 11 additions & 33 deletions frontend/src/pages/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,6 @@ import styles from './Overview.module.css'
const RANGE_COOKIE = 'cotel_overview_range'
const ANON_ID = '__anonymous__'

// /history is bounded by from/to rather than the range key, so "All" has to name
// a start date. Nothing cotel can store predates this by decades.
const HISTORY_EPOCH = '2000-01-01'

const RANGE_DAYS: Record<RangeKey, number | null> = {
all: null,
year: 365,
month: 30,
week: 7,
day: 1,
}

interface SectionProps {
range: RangeKey
userId?: string
Expand All @@ -43,10 +31,6 @@ function fmtTokens(n: number): string {
return String(n)
}

function isoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}

function formatDay(iso: string): string {
return new Date(iso).toLocaleDateString()
}
Expand Down Expand Up @@ -92,14 +76,14 @@ function UsersSection({ range }: { range: RangeKey }) {
)
}

function HistorySection({ range, userId, coveredSince }: SectionProps & { coveredSince: string | null }) {
const days = RANGE_DAYS[range]
const from = useMemo(
() => (days === null ? HISTORY_EPOCH : isoDate(new Date(Date.now() - days * 86400_000))),
[days],
function HistorySection({ range, userId }: SectionProps) {
const { data, isLoading, error } = useHistory(
range === 'day' ? 'hour' : 'day',
undefined,
undefined,
userId,
range,
)
const to = useMemo(() => isoDate(new Date()), [])
const { data, isLoading, error } = useHistory(range === 'day' ? 'hour' : 'day', from, to, userId)

if (isLoading && !data) return <ChartSkeleton />
if (error) return <ErrorState message={error.message} />
Expand All @@ -108,10 +92,10 @@ function HistorySection({ range, userId, coveredSince }: SectionProps & { covere

return (
<>
{coveredSince && (
{data.covered_since && (
<p className={styles.coverageNote}>
Charted from {formatDay(coveredSince)} — this chart is built from raw spans, and earlier
days in this range survive only as daily totals.
Charted from {formatDay(data.covered_since)} — hourly buckets are built from raw spans, and
earlier days in this range survive only as whole-day totals.
</p>
)}
<ResponsiveContainer width="100%" height={160}>
Expand Down Expand Up @@ -336,12 +320,6 @@ export default function Overview() {

const { data, error, isLoading, isValidating, mutate } = useOverview(paused ? 0 : 30_000, userId, range)

// History is charted from raw spans, so it starts at the same raw floor the
// sessions list reports. Same SWR key as the Sessions section below, so the
// two share one request rather than issuing two.
const { data: sessions } = useSessions(1, 5, 'start_time', 'desc', userId, range)
const coveredSince = sessions?.covered_since ?? null

const userParam = userId ? `?user_id=${encodeURIComponent(userId)}` : ''
const suffix = RANGE_SUFFIX[range]
const scoped = (label: string) => (suffix ? `${label} (${suffix})` : label)
Expand Down Expand Up @@ -403,7 +381,7 @@ export default function Overview() {
)}

<StatSection title="History" viewAllHref={`/history${userParam}`}>
<HistorySection range={range} userId={userId} coveredSince={coveredSince} />
<HistorySection range={range} userId={userId} />
</StatSection>

<StatSection title="Costs" viewAllHref={`/costs${userParam}`}>
Expand Down
Loading