Writing 12 min read

Watching my AI agents through Kong: metrics, logs and one shared timeline

In my last Kong post I broke requests one at a time and read the evidence by hand. This time I asked what the gateway can tell me over hours and across several agents at once, and put its metrics next to the model server and the GPUs.

In my last Kong post, I broke requests on purpose and followed each failure by hand: the response headers, one file-log line, the error log, the model server’s counter. That works when you already know which request to look at. Most of the questions I used to get in support started earlier than that: something is slow, something is using up the GPUs, it was fine yesterday. To answer those you need to see what happened over hours, not one request.

A gateway is in a good position to see that. Every request from every application goes through it, it knows who sent each one (the API key), and Kong’s AI plugins already count the tokens. So for this post I left the request path alone and added the other half: something that collects what Kong records, keeps it, and shows it next to what the model server and the GPUs were doing at the same moment.

The question I wanted to be able to answer by looking: when several coding agents share one local model, which one is doing the work, and what does that cost the model server?

The setup

Since the last post the lab has moved onto Proxmox. Kong now runs on its own VM, the agents on another, and a third VM does nothing but watch.

The lab, with telemetry Agents, one gateway, one model, and a VM that watches all three
The request path and the telemetry path in the lab On a Proxmox host, the VM client01 runs the agents: Claude Code and Anthropic SDK scripts sharing one API key, and OpenAI SDK scripts with another. They call Kong Gateway 3.16 on the VM kong01 over HTTPS. Kong runs key-auth, rate-limiting and ai-proxy on the request, and the prometheus and file-log plugins record it. ai-proxy forwards the request to SGLang on the GPU workstation bigai. A third VM, obs01, runs Prometheus, Loki and Grafana. Prometheus pulls Kong's metrics from its status API and SGLang's and the GPUs' metrics from bigai; Grafana Alloy on kong01 sends Kong's request log to Loki. PROXMOX · THREE LAB VMS BIGAI · 2× RTX PRO 6000 BLACKWELL CLIENT01 Coding agents Claude Code, Anthropic and OpenAI SDK scripts two API keys KONG01 · KONG GATEWAY 3.16 · HTTPS :8443 key-auth rate-limit ai-proxy prometheus file-log status API :8100/metrics, per consumer: requests, tokens, time to first token, time per token file-log: one JSON line per request, with the User-Agent SGLANG · :8000 Qwen 3.8 Flash Next /metrics: queue, KV cache, prefix-cache reuse, throughput GPU metrics: NVIDIA DCGM LAN OBS01 · OBSERVABILITY Prometheus · Loki · Grafana metrics every 15 seconds, the request log, dashboards and alert rules, kept for 30 days Prometheus pulls /metrics Alloy ships the log Prometheus pulls SGLang + GPUs
Solid: the request path. Dashed: the telemetry. Nothing on the request path changed for this post. Kong's prometheus and file-log plugins were already configured, and SGLang already exposed its metrics. The new part is the VM that collects all of it and puts it on one timeline.
  • kong01: Kong Gateway 3.16 in Docker, DB-less, one declarative YAML file. Clients use HTTPS. Two routes go through ai-proxy: /v1/messages (the Anthropic API, which Claude Code speaks) and /v1/chat/completions (OpenAI’s).
  • client01: the agents. Claude Code and the Anthropic SDK scripts share one API key, and the OpenAI SDK scripts use another. In Kong each key belongs to a consumer, so claude-code and openai-apps are the two identities the gateway sees.
  • bigai: SGLang serving Qwen 3.8 Flash Next on two RTX PRO 6000 Blackwell cards, unchanged from my earlier posts.
  • obs01: Prometheus, Loki and Grafana. Prometheus scrapes Kong, SGLang and the GPUs every 15 seconds. A small agent on kong01 (Grafana Alloy) sends Kong’s request log to Loki.

Turning on Kong’s metrics

Kong’s Prometheus plugin exposes metrics on the status API. The plugin’s documentation says most of the detailed metrics are off by default, because they can create a lot of series. These are the switches I turned on, as a global plugin:

plugins:
  - name: prometheus
    config:
      per_consumer: true          # a consumer label on requests and bandwidth
      status_code_metrics: true   # kong_http_requests_total
      latency_metrics: true       # Kong's own time, the upstream's, the total
      bandwidth_metrics: true
      upstream_health_metrics: true
      ai_metrics: true            # requests, tokens, time to first token, per consumer

ai-proxy needs logging.log_statistics: true on each route for the token counts, which I already had from the last post. I kept log_payloads: false, so no prompt or answer ever reaches a log.

The status API was already listening, so this is all it took. One thing confused me for a minute: right after a restart, /metrics showed only memory, connections and node information, and none of the AI metrics. They appear with the first request through a route that uses them. Prometheus then scrapes them like anything else. These are the series one Claude Code request lands in, with that request’s values (trimmed):

kong_ai_llm_requests_total{ai_provider="anthropic",ai_model="qwen38-27b",
    consumer="claude-code",request_mode="stream",...} 1
kong_ai_llm_tokens_total{...,token_type="prompt_tokens",consumer="claude-code"} 49226
kong_ai_llm_tokens_total{...,token_type="completion_tokens",consumer="claude-code"} 83
kong_ai_llm_ttft_latency_ms_bucket{...,consumer="claude-code",request_mode="stream",le="250"} 1
kong_http_requests_total{service="llm-anthropic",route="anthropic-messages",code="200",
    consumer="claude-code",...} 1
kong_kong_latency_ms_bucket{service="llm-anthropic",route="anthropic-messages",le="10"} 1

Two things in there matter for everything below. The AI metrics carry a consumer label (Kong added it in 3.11), so I can see tokens per agent. And ai_provider is anthropic here even though the model is Qwen. That is the provider type configured on the route (SGLang also speaks Anthropic’s API), not who made the model.

Putting the agents on a timeline

To have something to look at, I ran five agents at once for eight minutes: one real Claude Code session doing small read-only tasks in a sample repository, two Anthropic SDK scripts that hold a growing coding conversation, and two OpenAI SDK scripts asking short questions. All of them went through Kong.

A Grafana dashboard over twelve minutes. Top: requests per second by Kong consumer (claude-code and openai-apps, together just under 1 per second), requests by program (Anthropic SDK, OpenAI SDK, claude-cli), and prompt tokens per second (about 8,000, almost all claude-code) and output tokens per second (about 200). Bottom: SGLang's decode throughput in bursts up to about 350 tokens per second, up to four requests running and none waiting, prompt tokens split into a small computed share and a large share reused from the cache, and the KV cache under 15% full.

Eight minutes of five agents, from Grafana. Top: what Kong recorded, per consumer and per program. Bottom: what SGLang was doing at the same moment. Prompt tokens dwarf output tokens, and most of the prompt came from SGLang’s cache (grey, bottom left).

The top half is the gateway’s view: requests and tokens, per consumer and per program. The bottom half is SGLang and the GPUs. Because they share the time axis and the crosshair, a jump in one agent’s tokens lines up with the decode throughput, the queue and the GPU power at the same moment.

In those eight minutes Kong saw 445 requests to the model and no errors. 82% of them were streamed. The 45 small tasks I gave Claude Code turned into 134 requests: an agent goes back to the model after each tool it uses.

What the metrics told me

The consumer is the key, not the program

Kong’s metrics answer “which consumer”, and a consumer is an API key. In this lab Claude Code and an Anthropic SDK script both used the claude-code key, and to Kong’s metrics they were one agent.

The request log has the answer, because each file-log line includes the request headers. Here is one line from Claude Code, trimmed to the fields I used:

{"consumer": "claude-code", "route": "anthropic-messages", "status": 200,
 "user-agent": "claude-cli/2.1.300 (external, cli)",
 "latencies": {"kong": 10, "proxy": 135, "request": 1167},
 "ai": {"prompt_tokens": 49226, "completion_tokens": 83,
        "time_to_first_token": 137, "time_per_token": 13.95}}

The User-Agent says claude-cli; the SDK scripts say Anthropic/Python and OpenAI/Python. Alloy reads each line as it is written and counts requests and tokens per program as well as per consumer, so the dashboard can split one key into the tools behind it. It is a workaround. The cleaner answer is one consumer per application, which Kong makes easy. But in a support case you get the configuration the customer has, and the log already holds the detail.

In the dashboard above, that is the panel at the top right: the claude-code consumer on the left is really two programs, the Anthropic SDK scripts and Claude Code itself.

Kong’s own time stays small

kong_kong_latency_ms is the time Kong itself adds: the plugins, authentication, routing and ai-proxy’s rewriting of the request. Over the eight-minute run it was 0.8 ms at the median and 3.9 ms at the 95th percentile. The largest was 15 ms, on a 22,000-token Claude Code request, which ai-proxy has to parse; the line above shows 10 ms for a 49,000-token one. That is consistent with the last post, where I measured about a millisecond for small requests. Next to a model that needs seconds, the gateway is not where the time goes.

Time to first token is really time to the response headers

kong_upstream_latency_ms sounds like “how long the model took”. For a streamed response, it isn’t. In the log line above, the request took 1,167 ms in total, but the upstream latency (proxy) was 135 ms, almost exactly the logged time to the first token (137 ms). That wasn’t a coincidence. Across all 1,615 successful streamed requests in Kong’s log, the two numbers were within 2 ms of each other, and the upstream latency was a median of 3% of the whole request. For a stream, both end when the response starts. The rest of the answer streams in after that and only shows up in the total.

Then the dashboard showed something that didn’t fit. Kong reported a median time to first token of 50 ms for the claude-code consumer, while SGLang’s own median over the same eight minutes was 198 ms. A model can’t start answering a 10,000-token prompt that quickly. So I sent one streamed request with a new 7,000-word prompt through each route and timed every event on the client:

One streamed request Response headers arrived First token arrived Kong logged as time to first token
Anthropic route (/v1/messages) 90 ms 2,562 ms 83 ms
OpenAI route (/v1/chat/completions) 2,521 ms 2,521 ms 2,516 ms

Kong’s number matches when the headers arrived, not when the first token did. On the OpenAI route that makes no difference, because SGLang sends its headers together with the first token. On the Anthropic route SGLang sends the headers right away, before the model has produced anything, so Kong’s “time to first token” left out the whole 2.5 seconds the model spent reading the prompt.

To find out why, I read the code. Kong’s Lua ships as plain text inside the image, so the cause is visible, even though this part of Kong isn’t open source. ai-proxy records the time to first token the first time its streaming filter runs, before it looks at what arrived. My streaming routes use response_buffering: false, so that events are passed on as they arrive. In that mode Nginx runs the response filters once with an empty chunk as soon as the upstream’s headers come in. That first, empty run is the moment Kong records. Two throwaway Kong nodes that differed only in that one setting confirmed it: with buffering on, Kong’s time to first token was right.

Turning buffering on isn’t a fix, though. Over HTTPS, which is how my clients connect, Kong then held the whole streamed answer back and delivered it in one piece at the end. So my streaming routes keep buffering off, and I read the time to first token from SGLang.

I reported it to Kong, with a reproduction that needs no GPU: a small fake model server that sends its headers at once and its first token two seconds later. It’s Kong/kong#15051.

Two charts over the same eight minutes. Left: Kong's 95th-percentile time to first token per consumer; claude-code stays flat near zero until a single non-streamed request at the end, while openai-apps moves between 2 and 6 seconds. Right: SGLang's own 95th-percentile time to first token, mostly between 1 and 2 seconds, with the queue wait at zero.

The same eight minutes. Left: Kong’s time to first token per consumer. The claude-code line stays near zero because it is really the time to the response headers (#15051). The openai-apps line is higher partly because its non-streamed requests count the whole answer. Right: SGLang’s own time to first token, 1 to 2 seconds at the 95th percentile.

So for an LLM route I now read the numbers like this:

Question Where I look
Is the gateway slow? kong_kong_latency_ms
How long until the user sees text? the model server’s own time to first token. Kong’s kong_ai_llm_ttft_latency_ms only if that upstream sends its headers together with the first token
How fast does the answer flow once it starts? kong_ai_llm_tpot_latency_ms, the time per output token
What did the whole request take? kong_request_latency_ms, or latencies.request in the log

For a request that is not streamed, the “first token” is the whole answer, so its time to first token equals the whole response time.

Check where the histogram stops

Prometheus histograms count values into fixed buckets, and percentiles are estimated from them. In Kong 3.16 the largest bucket for time to first token is 30 seconds. Time per token stops at 10 seconds, and upstream and total latency at 60 seconds. Anything above the largest bucket is counted, but only as “more than that”. So when a p95 line sits flat at exactly 30 seconds, it means “30 seconds or more”.

With coding agents that is not hypothetical. A long session can send prompts of well over 100,000 tokens, and processing that much input before the first token takes a while. That is one more reason I read the time to first token from SGLang, whose histogram goes to 400 seconds.

Tokens mean something different on each side

The gateway counts what was sent. In the eight-minute run, the claude-code consumer sent 4.1 million prompt tokens and got back 80,000, about 51 to 1. Coding agents resend their whole conversation on every turn, so the prompt is most of the traffic. The OpenAI scripts, asking short questions, went the other way: 9,300 tokens in, 25,900 out.

The model server sees a different picture. SGLang keeps recently used prompts in a prefix cache, and a prompt that starts with the same conversation as the last turn doesn’t have to be computed again. Over the same eight minutes SGLang computed 0.76 million prompt tokens and took 3.34 million from its cache, so 81% of the prompt never had to be computed again. Kong’s prompt-token count and SGLang’s own were within 1% of each other: the two sides agree on what came in, and differ on what it cost. Neither side is wrong. Kong counts what the application asked for, which is what you would bill or limit. SGLang shows what the GPUs actually had to do. Put next to each other, they explain why a token-heavy agent may cost much less GPU time than its token count suggests, as long as its prompts reuse the cache.

When something does go wrong

The day after I set this up, SGLang crashed overnight on an idle server. Afterwards, the dashboards showed what each layer had seen. Nobody sent anything through Kong overnight, so the gateway had nothing to report. The checks on the model server noticed within four minutes: Prometheus could no longer scrape SGLang, and a small test request sent straight to it failed. The next requests from the claude-code consumer came back from Kong as 502s, labeled with that consumer. The cause wasn’t in any of those places: it was a CUDA error in SGLang’s log and a GPU error in the kernel log on bigai.

That is the same lesson as the last post, over a longer stretch of time. The gateway is the best place to see that something is wrong and who it affects. It is usually not the place that knows why. And when nobody is calling, it has nothing to say, so the model server needs its own checks.

What I would write down for next time

  1. Turn the metrics on deliberately. Most of the Prometheus plugin’s detailed metrics are off by default, and ai_metrics is a separate switch. ai-proxy also needs log_statistics for token counts.
  2. Expect the AI series only after traffic. No AI metrics in /metrics right after a restart is normal.
  3. Use the AI metrics for “which agent”. The general latency histograms have no consumer label.
  4. For streams, upstream latency ends at the response headers, and in Kong 3.16 so does time to first token on routes with response_buffering: false (#15051). Check when the model server sends its headers before trusting either as “time until the user saw text”.
  5. Check the top bucket before trusting a percentile. TTFT stops at 30 s, upstream and total at 60 s.
  6. A consumer is a key, not a program. One consumer per application keeps the metrics honest; otherwise the User-Agent in the request log can split them.
  7. Put the gateway next to the model server. Kong says who sent what and when; the model server says what it cost.

What is in the box

Piece Version Role
Kong Gateway 3.16.0.1 (kong/kong-gateway image), DB-less, in Docker on a Proxmox VM key-auth, rate-limiting, ai-proxy, prometheus, file-log
Prometheus 3.15 collects Kong’s, SGLang’s and the GPUs’ metrics every 15 s, keeps 30 days
Loki + Grafana Alloy 3.7 / 1.20 Kong’s request log, one JSON line per request
Grafana 13.2 the dashboards
NVIDIA DCGM exporter 4.8 GPU activity, power, temperature, fans
SGLang + Qwen 3.8 Flash Next the server from my earlier posts, on bigai every answer
Claude Code 2.1.300 one of the agents, through Kong

The usual caveats apply: one gateway node, one model, a handful of agents on a quiet LAN, and only what I could see from the outside. There is more in Kong for this than I used. The OpenTelemetry plugin can trace a request through the gateway, and Konnect has its own analytics; I haven’t tried either yet. Next I want to use these dashboards for the thing they made obvious: several agents compete for one model server that runs at most eight requests at a time. That makes Kong’s traffic controls, token-based limits in particular, my next experiment.