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

### Added
- `scripts/seed-demo.py` fills a throwaway instance with a synthetic team — seven users, three models, 90 days of sessions and tool calls. It goes in over the OTLP endpoint rather than writing to DuckDB, so a seeded instance exercises the same ingest, cost-derivation and roll-up path a real one does, and it sends only attributes Claude Code actually sends: no `command` on `Bash` spans, so the Tools page shows the same "no command detail" state a real install sees. The RNG seed is fixed, so a re-run against a fresh volume reproduces the same numbers. `scripts/shoot-screenshots.mjs` turns that instance into the README images, each cropped at the bottom edge of a named element rather than at a pixel count ([docs/operations/screenshots.md](docs/operations/screenshots.md))
- `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

Expand All @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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 README screenshots are of the software as it now is. The single hero shot predated the Overview rework — it showed the deleted user-search cloud, Sessions at the top, KPI labels hardcoded to `(30d)` and no range switcher. It is replaced by the current Overview plus a Screens gallery of Sessions, Costs, Users and Tools, all taken from the demo seed, so no real user name or real spend is published. The README's logo `<img>` pointed at `assets/logo.svg`, a path that does not exist in the repo and rendered as a broken image on GitHub; it now points at `docs/assets/logo.svg`. The current-release tag reads `:0.3`, not `:0.2`
- 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
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<img src="assets/logo.svg" alt="Flopsstuff logo" width="80">
<img src="docs/assets/logo.svg" alt="Flopsstuff logo" width="80">

# cotel — Claude Code Telemetry

One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on `:8080`. No cloud dependencies, no sign-up.

![cotel dashboard](docs/assets/dashboard-screenshot.png)
![cotel Overview](docs/assets/dashboard-overview.png)

## What you get

Expand All @@ -19,6 +19,15 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on
- **Export / Import** — download all data as a versioned ZIP/CSV archive; restore it on a fresh instance
- **Cloudflare Tunnel** — publish cotel over HTTPS with a single env var; bearer-token auth for OTLP, Zero Trust for the dashboard

## Screens

Every screenshot here is one instance seeded with synthetic telemetry — see [Demo data](#demo-data).

| | |
|:--|:--|
| **Sessions** — every session with its user, model, tokens, cost and OK / ERROR status<br>![Sessions](docs/assets/dashboard-sessions.png) | **Costs** — daily spend and cost by model across the window you pick<br>![Costs](docs/assets/dashboard-costs.png) |
| **Users** — cost and session count per principal for the selected range<br>![Users](docs/assets/dashboard-users.png) | **Tools** — call count, average duration and error rate per tool<br>![Tools](docs/assets/dashboard-tools.png) |

## Quick start

```bash
Expand All @@ -30,10 +39,27 @@ docker run -d \
ghcr.io/flopsstuff/cotel:latest
```

> **Available tags:** `:latest` and `:0.2` (current release), `:0.x.y` (patch), `:main` (tip of main branch).
> **Available tags:** `:latest` and `:0.3` (current release), `:0.x.y` (patch), `:main` (tip of main branch).

Open **http://localhost:8080** → **Setup** for the guided onboarding.

## Demo data

An empty cotel shows empty charts, which makes it hard to judge. `scripts/seed-demo.py`
fills a throwaway instance with a synthetic team — seven users, three models, 90 days
of sessions and tool calls — over the real OTLP endpoint, so what you see is what
ingest actually produces:

```bash
python3 scripts/seed-demo.py --dash-url http://localhost:8080 \
--ingest-url http://localhost:4318
```

It only creates users and ingests spans; it never deletes. Point it at an instance
you are willing to throw away, not at one holding real telemetry. The screenshots in
this README come from exactly this seed — the recipe is in
[docs/operations/screenshots.md](docs/operations/screenshots.md).

## Point Claude Code at cotel

Add to your `~/.claude/settings.json`:
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default defineConfig({
{ text: 'Cloudflare Tunnel — Token Mode', link: '/operations/cloudflare-tunnel-remote' },
{ text: 'Cloudflare Tunnel — Local Config', link: '/operations/cloudflare-tunnel-local' },
{ text: 'Export / Import', link: '/operations/export-import' },
{ text: 'README Screenshots', link: '/operations/screenshots' },
],
},
],
Expand Down
3 changes: 3 additions & 0 deletions docs/assets/dashboard-costs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/assets/dashboard-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 0 additions & 3 deletions docs/assets/dashboard-screenshot.png

This file was deleted.

3 changes: 3 additions & 0 deletions docs/assets/dashboard-sessions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/assets/dashboard-tools.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/assets/dashboard-users.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions docs/assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions docs/operations/screenshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Re-taking the README screenshots

The images in `docs/assets/dashboard-*.png` are produced from a throwaway instance
seeded with synthetic telemetry, never from a real one. Real telemetry carries real
user names and real spend, and a screenshot is forever.

Redo them whenever a page in the shot changes shape.

## 1. Build the image under test

```bash
docker build -t cotel:shots .
```

## 2. Run it on a fresh volume, with retention off

The seed reaches 90 days back; the shipped 30-day raw retention would roll most of
it into `daily_usage` mid-run and the session rows would vanish from the list.

```bash
docker run -d --name cotel-shots \
-p 14318:4318 -p 18080:8080 \
-e COTEL_RETENTION_RAW_DAYS=3650 \
-e COTEL_RETENTION_AGGREGATE_DAYS=3650 \
-v cotel-shots-data:/data \
cotel:shots
```

## 3. Seed it

```bash
python3 scripts/seed-demo.py --dash-url http://localhost:18080 \
--ingest-url http://localhost:14318
```

Takes a couple of minutes and ingests ~25 000 spans across ~485 sessions. The RNG
seed is fixed, so a re-run against a fresh volume reproduces the same numbers.
Ingest is queued behind the HTTP response — wait for `span_count` in
`curl -s localhost:18080/api/v1/health` to stop climbing before shooting.

## 4. Shoot

```bash
BASE=http://localhost:18080 node scripts/shoot-screenshots.mjs
```

1440×1400 viewport at 2× DPR, dark scheme, each page cropped at the bottom edge of
a named element. The files land straight in `docs/assets/`.

`playwright-core` has to be resolvable from the repo — `npx playwright-core@latest
--help` once is enough to populate the npx cache, then symlink or set `NODE_PATH` to
it. Chromium comes from `CHROMIUM` (default `/usr/bin/chromium`); this box has no
Playwright-managed browser.

## 5. Tear down

```bash
docker rm -f cotel-shots && docker volume rm cotel-shots-data
```

## What not to fake

The seeder sends only the attributes Claude Code actually sends. In particular it
does not attach `command` to `Bash` spans, so the Tools page's Bash breakdown shows
its "no command detail in this data" state — which is what a real install sees.
Seeding it would make the README advertise a view nobody gets.
Loading