Skip to content

feat: add performance view with time to first token and concurrency - #9

Merged
tito merged 7 commits into
mainfrom
feature/add-performance-pane-658
Oct 8, 2026
Merged

tito merged 7 commits into
mainfrom
feature/add-performance-pane-658

Conversation

@tito

@tito tito commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Why

The Usage tab shows how much traffic and cost passed through the proxy, and the Errors view shows what failed. Nothing shows how fast the proxy and its providers answered. An operator who asks "how long did requests take last week", "how long before the first token", "how many tokens per second does this model produce" or "how many requests ran at the same time" cannot answer from the UI. The database stores total duration per request but never shows it, and it stores nothing about the first token.

What

A new "Performance" view under the Usage tab shows, for the selected range and the shared filters, the request duration, the time to first token, the output speed in tokens per second, and the number of concurrent requests. Each figure shows a mean, a median (p50) and a 95th percentile (p95) in tiles; a chart plots p50 and p95 per time bucket, and a table breaks the range down per model. The mean is kept because it was asked for, and p50 and p95 sit next to it because LLM latency is skewed and a mean alone hides slow requests. Time to first token is the time from the upstream call to the first event that carries generated content (an Anthropic content_block_delta, an OpenAI delta with text, reasoning or tool calls), because the first byte arrives before any token on Anthropic and would under-report. The streaming relay records only that timestamp and stops looking after the first match; no content is kept. Concurrency comes from the stored rows (start is end minus duration): the average per bucket is busy time divided by bucket length and the peak comes from a sweep over start and end points, because this works on past data and across several proxy instances without a new table.

Request Response
GET /stats/performance?since=…&until=…&bucket=hour plus the shared stats filters {"bucket":"hour","summary":{…},"series":[…],"models":[…]}
summary and each series entry requests, measured, duration_ms, ttft_ms, tokens_per_second (each {mean,p50,p95} or null), concurrency {average,peak}
series one entry per bucket from since to until, empty buckets with null figures
models one row per provider and alias with the same figures
range over the event cap 400 range_too_large
GET /stats/requests and GET /admin/v1/requests rows gain ttft_ms (null on unary requests)

Where to look, in reading order:

  • internal/store/schema.go, migrate.go, types.go, usage.go: nullable ttft_ms on usage_event, migration 011, PerformanceEvents query.
  • internal/server/usagerec.go: openAIFirstContent and anthropicFirstContent, TTFTMs on usageOutcome.
  • internal/server/openai.go, transparent.go: the relay loops check each data: line until the first content event, then stop.
  • internal/server/performance.go: handleStatsPerformance, percentiles, the concurrency sweep, gap-filled series.
  • internal/server/server.go, admin.go: the route and ttft_ms in the request log.
  • ui/src/components/PerformanceDashboard.tsx, charts.tsx (LineChart), App.tsx, lib/api.ts: the view, the sub-nav entry, the request-log column.
  • docs/concepts/architecture.md, docs/guides/usage.md, docs/reference/api.md, docs/reference/openapi.yaml.

The stream sniffer parses lines that do not contain "usage". The current rule says only usage lines are parsed. A first-token time cannot come from usage lines, and the first body byte is too early on Anthropic (message_start and ping precede generation). The new check runs only until the first match, and returns a bool that never leaves the function.

Before After
Lines parsed lines containing "usage" lines containing "usage", plus each data: line until the first content event
Kept from a parsed line usage numbers usage numbers, and one timestamp
Cost per stream after first token unchanged unchanged (check disabled)

usage_event gains ttft_ms. Nullable, so past rows and non-streamed rows read as "unknown" rather than zero. The name passes the content-shaped column regex in persistence_test.go.

Before After
Timing columns duration_ms duration_ms, ttft_ms (nullable)

Results

Same seeded database (50,608 events over 7 days from the fake-upstream seed script), main binary on one port and this branch on another, same session cookie.

Before (main, 5943b81) After (25ffd9b)
GET /stats/performance?since=-24h&bucket=hour 404 unknown_endpoint 200, summary.requests 9526, measured 9356, duration_ms p50 4175 / p95 24245, ttft_ms p50 498 / p95 1635, tokens_per_second p50 96.9 / p95 199.1, concurrency average 0.76 / peak 14; series 25 buckets; models 5 rows
/usage/performance no such view tiles, four p50/p95 charts, per-model table (performance-24h-light.png, performance-7d-dark.png, performance-7d-qwen-light.png)
/requests no first-token column (request-log-before-dark-top.png) "First token" column, dash on embeddings rows (request-log-after-dark-top.png)

Screenshot files are named above and are added through the web UI after the PR is opened.

Left alone

  • Prometheus histograms for time to first token and duration: a separate change to /metrics.
  • A live "in flight now" gauge: not chosen; concurrency is derived from stored rows only.
  • Proxy overhead (time spent before the upstream call): not measured; a separate change if needed.
  • Time to first token for non-streamed requests: there is no first token to observe.
  • Requests that span a bucket boundary count in the bucket where they end for latency figures, but are split across buckets for average concurrency. The usage guide states this.
  • Large ranges load one compact row per event into memory. A fixed cap of 500,000 events and the 400 range_too_large refusal bound it; percentiles are not portable across SQLite and Postgres in SQL, and the sweep needs individual start and end points anyway.
  • ev.TS is set at the end of the relay instead of at insert, so derived start times are not shifted by the async recorder's delay.

How to verify

go test ./internal/store -run 'TTFT|Migrate'
go test ./internal/server -run 'TimeToFirstToken|FirstContent|StatsPerformance|Persistence'
go test -race -count=1 ./...
golangci-lint run
cd ui && npm ci && npm run build && git status --short ../internal/server/uidist
mkdocs build --strict
  1. Run the stress harness or the playground for a few streamed chat requests, then open /usage/performance. The tiles show duration, time to first token and tokens per second with mean, p50 and p95, and concurrency with average and peak.
  2. Query GET /stats/performance?since=...&bucket=hour. The response holds summary, series (one entry per hour, empty hours with null figures) and models.
  3. Send one non-streamed chat request and one embeddings request. In the request log both rows have no time to first token, and the time-to-first-token tiles do not move.
  4. Send 5 parallel streamed requests of about 10 s each. The peak concurrency for that bucket is 5.
  5. Query with since at the start of time on a database over the event cap. The response is a 400 range_too_large.
  6. Run go test ./internal/server -run Persistence. The content-leak tests still pass with the new column.

@tito
tito merged commit b281131 into main Oct 8, 2026
5 checks passed
@tito
tito deleted the feature/add-performance-pane-658 branch October 8, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant