Repository navigation
feat: add performance view with time to first token and concurrency - #9
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.GET /stats/performance?since=…&until=…&bucket=hourplus the shared stats filters{"bucket":"hour","summary":{…},"series":[…],"models":[…]}summaryand eachseriesentryrequests,measured,duration_ms,ttft_ms,tokens_per_second(each{mean,p50,p95}ornull),concurrency{average,peak}seriessincetountil, empty buckets withnullfiguresmodels400 range_too_largeGET /stats/requestsandGET /admin/v1/requestsrowsttft_ms(nullon unary requests)Where to look, in reading order:
internal/store/schema.go,migrate.go,types.go,usage.go: nullablettft_msonusage_event, migration 011,PerformanceEventsquery.internal/server/usagerec.go:openAIFirstContentandanthropicFirstContent,TTFTMsonusageOutcome.internal/server/openai.go,transparent.go: the relay loops check eachdata: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 andttft_msin 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_startandpingprecede generation). The new check runs only until the first match, and returns a bool that never leaves the function."usage""usage", plus eachdata:line until the first content eventusage_eventgainsttft_ms. Nullable, so past rows and non-streamed rows read as "unknown" rather than zero. The name passes the content-shaped column regex inpersistence_test.go.duration_msduration_ms,ttft_ms(nullable)Results
Same seeded database (50,608 events over 7 days from the fake-upstream seed script),
mainbinary on one port and this branch on another, same session cookie.main, 5943b81)GET /stats/performance?since=-24h&bucket=hour404 unknown_endpoint200,summary.requests9526,measured9356,duration_msp50 4175 / p95 24245,ttft_msp50 498 / p95 1635,tokens_per_secondp50 96.9 / p95 199.1,concurrencyaverage 0.76 / peak 14;series25 buckets;models5 rows/usage/performanceperformance-24h-light.png,performance-7d-dark.png,performance-7d-qwen-light.png)/requestsrequest-log-before-dark-top.png)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
/metrics.400 range_too_largerefusal bound it; percentiles are not portable across SQLite and Postgres in SQL, and the sweep needs individual start and end points anyway.ev.TSis 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
/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.GET /stats/performance?since=...&bucket=hour. The response holdssummary,series(one entry per hour, empty hours with null figures) andmodels.sinceat the start of time on a database over the event cap. The response is a 400range_too_large.go test ./internal/server -run Persistence. The content-leak tests still pass with the new column.