Skip to content
Draft
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
29 changes: 20 additions & 9 deletions bodylog-exporter-go/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,19 @@ type detailRecord struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
CachedTokens int64 `json:"cached_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
// actual_usage's cached_tokens (some sglang builds report a second count next to usage);
// absent from the line when the response had none
ActualCachedTokens *int64 `json:"actual_cached_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
}

type metrics struct {
requests *prometheus.CounterVec // {backend,model,status_class,stream}
promptTok *prometheus.CounterVec // {backend,model}
completionTok *prometheus.CounterVec
cachedTok *prometheus.CounterVec
actualCachedTok *prometheus.CounterVec
reasoningTok *prometheus.CounterVec
totalTok *prometheus.CounterVec
reqBytes *prometheus.CounterVec
Expand Down Expand Up @@ -84,12 +88,16 @@ func newMetrics(reg *prometheus.Registry) *metrics {
promptTok: ctr("bodylog_prompt_tokens_total", "prompt token 累计", srbm),
completionTok: ctr("bodylog_completion_tokens_total", "completion token 累计", srbm),
cachedTok: ctr("bodylog_cached_tokens_total", "cached token 累计", srbm),
reasoningTok: ctr("bodylog_reasoning_tokens_total", "reasoning token 累计", srbm),
totalTok: ctr("bodylog_total_tokens_total", "total token 累计", srbm),
reqBytes: ctr("bodylog_req_bytes_total", "请求体字节累计", srbm),
respBytes: ctr("bodylog_resp_bytes_total", "响应体字节累计", srbm),
finishReason: ctr("bodylog_finish_reason_total", "按 finish_reason 计数", []string{"service", "route", "backend", "model", "finish_reason"}),
rt: hist("bodylog_rt_seconds", "总响应时间(秒)", []string{"service", "route", "backend", "model", "stream"}),
// usage.prompt_tokens_details.cached_tokens is what clients and billing see; some sglang
// builds also send actual_usage with the engine's own count, which can differ. This is that
// one, counted only for responses that reported it.
actualCachedTok: ctr("bodylog_actual_cached_tokens_total", "actual_usage.prompt_tokens_details.cached_tokens 累计(仅上游返回 actual_usage 的请求)", srbm),
reasoningTok: ctr("bodylog_reasoning_tokens_total", "reasoning token 累计", srbm),
totalTok: ctr("bodylog_total_tokens_total", "total token 累计", srbm),
reqBytes: ctr("bodylog_req_bytes_total", "请求体字节累计", srbm),
respBytes: ctr("bodylog_resp_bytes_total", "响应体字节累计", srbm),
finishReason: ctr("bodylog_finish_reason_total", "按 finish_reason 计数", []string{"service", "route", "backend", "model", "finish_reason"}),
rt: hist("bodylog_rt_seconds", "总响应时间(秒)", []string{"service", "route", "backend", "model", "stream"}),
// prompt_bucket:输入长度分档,用于“某 context 区间的 TTFT 分位数”。见 promptBucket()。
ttft: hist("bodylog_ttft_seconds", "首 token 时间/TTFT(秒,仅流式)", srbmp),
// 2026-08-26:分母从 rt 改为 rt-frt(扣除 prefill),仅流式 + 下限过滤,与 openresty 引擎
Expand All @@ -106,7 +114,7 @@ func newMetrics(reg *prometheus.Registry) *metrics {
recovers: prometheus.NewCounter(prometheus.CounterOpts{Name: "bodylog_exporter_recovery_total", Help: "跨天缺口 HTTP 补读次数"}),
}
reg.MustRegister(
m.requests, m.promptTok, m.completionTok, m.cachedTok, m.reasoningTok, m.totalTok,
m.requests, m.promptTok, m.completionTok, m.cachedTok, m.actualCachedTok, m.reasoningTok, m.totalTok,
m.reqBytes, m.respBytes, m.finishReason, m.rt, m.ttft, m.outTokPerSec,
m.overallTokPerSec, m.decodeTokPerSec,
m.lines, m.offset, m.lastTs, m.recovers,
Expand Down Expand Up @@ -173,6 +181,9 @@ func (m *metrics) observe(d detailRecord) {
if d.CachedTokens > 0 {
m.cachedTok.WithLabelValues(service, route, backend, model).Add(float64(d.CachedTokens))
}
if d.ActualCachedTokens != nil && *d.ActualCachedTokens > 0 {
m.actualCachedTok.WithLabelValues(service, route, backend, model).Add(float64(*d.ActualCachedTokens))
}
if d.ReasoningTokens > 0 {
m.reasoningTok.WithLabelValues(service, route, backend, model).Add(float64(d.ReasoningTokens))
}
Expand Down
48 changes: 48 additions & 0 deletions bodylog-exporter-go/metrics_actual_cached_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"testing"

"github.com/prometheus/client_golang/prometheus"
)

// counterTotal sums every series of one counter family.
func counterTotal(t *testing.T, reg *prometheus.Registry, name string) float64 {
t.Helper()
fams, err := reg.Gather()
if err != nil {
t.Fatal(err)
}
var sum float64
for _, f := range fams {
if f.GetName() == name {
for _, m := range f.GetMetric() {
sum += m.GetCounter().GetValue()
}
}
}
return sum
}

// Some sglang builds report a second token count, actual_usage, next to usage; the listener
// writes its cached_tokens to the details line as actual_cached_tokens. It gets its own counter:
// bodylog_cached_tokens_total keeps counting usage's figure (what clients and billing see).
func TestActualCachedTokensCounter(t *testing.T) {
reg := prometheus.NewRegistry()
tl := &tailer{m: newMetrics(reg)}
line := func(extra string) string {
return `{"ts":"2026-11-10T16:00:00+08:00","ts_end":"2026-11-10T16:00:01+08:00","request_id":"r",` +
`"stream":true,"status":200,"backend":"10.0.0.4:8050","model":"kimi-k3","finish_reason":"stop",` +
`"frt":0.5,"lct":1.0,"rt":1.5,"prompt_tokens":4096,"completion_tokens":10,"cached_tokens":2048` + extra + `}` + "\n"
}
tl.handleLine([]byte(line(`,"actual_cached_tokens":3072`)))
tl.handleLine([]byte(line(`,"actual_cached_tokens":0`))) // reported, nothing cached
tl.handleLine([]byte(line(``))) // a backend without actual_usage

if got := counterTotal(t, reg, "bodylog_cached_tokens_total"); got != 3*2048 {
t.Errorf("bodylog_cached_tokens_total = %v, want %v (usage's figure, every request)", got, 3*2048)
}
if got := counterTotal(t, reg, "bodylog_actual_cached_tokens_total"); got != 3072 {
t.Errorf("bodylog_actual_cached_tokens_total = %v, want 3072 (only what actual_usage reported)", got)
}
}
19 changes: 18 additions & 1 deletion bodylog-listener-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ type detailRecord struct {
CachedTokens int64 `json:"cached_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
// actual_usage.prompt_tokens_details.cached_tokens (some sglang builds send a second token
// count next to usage). Absent unless the response had actual_usage, so "not reported" and
// "reported 0" stay apart.
ActualCachedTokens *int64 `json:"actual_cached_tokens,omitempty"`
}

// extractDetail: 从 assembleEntry 解析好的 map 里抽出 metrics 明细,不带任何正文。
Expand Down Expand Up @@ -203,6 +207,10 @@ func extractDetail(m map[string]any) detailRecord {
TotalTokens: u.total,
ReasoningTokens: u.reasoning,
}
if u.hasActualCached {
v := u.actualCached
d.ActualCachedTokens = &v
}
if rm, ok := m["resp_meta"].(map[string]any); ok {
d.Model = getString(rm["model"])
d.FinishReason = getString(rm["finish_reason"])
Expand Down Expand Up @@ -1188,6 +1196,8 @@ func normalizePeer(m map[string]any) string {

type usageTokens struct {
prompt, cached, compl, total, reasoning int64
actualCached int64 // actual_usage.prompt_tokens_details.cached_tokens
hasActualCached bool // the response carried actual_usage with that field
}

// extractUsage: 从 resp_meta.usage 抽 token 计数,兼容 OpenAI(prompt/completion_tokens)
Expand All @@ -1200,6 +1210,13 @@ func extractUsage(m map[string]any) usageTokens {
if !ok {
return u
}
if au, ok := rm["actual_usage"].(map[string]any); ok {
if d, ok := au["prompt_tokens_details"].(map[string]any); ok {
if v, ok := d["cached_tokens"].(float64); ok { // a number; null or missing = not reported
u.actualCached, u.hasActualCached = int64(v), true
}
}
}
us, ok := rm["usage"].(map[string]any)
if !ok {
return u
Expand Down Expand Up @@ -1317,7 +1334,7 @@ const detailsColumns = "{" +
"'finish_reason':'VARCHAR','frt':'DOUBLE','lct':'DOUBLE','rt':'DOUBLE'," +
"'chunk_count':'BIGINT','req_bytes':'BIGINT','resp_bytes':'BIGINT'," +
"'prompt_tokens':'BIGINT','completion_tokens':'BIGINT','cached_tokens':'BIGINT'," +
"'total_tokens':'BIGINT','reasoning_tokens':'BIGINT'}"
"'total_tokens':'BIGINT','reasoning_tokens':'BIGINT','actual_cached_tokens':'BIGINT'}"

// readDetailsJSON: 用显式 schema 读明细 jsonl 的 DuckDB 表达式。arg 是单个 sqlStr 路径
// 或文件名列表(read_json 两者都接受)。rotateDetails 与 metricsHandler 共用,保证写/读
Expand Down
38 changes: 38 additions & 0 deletions bodylog-listener-go/resp_meta_actual_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"strings"
"testing"
)

Expand Down Expand Up @@ -58,3 +59,40 @@ func TestRespMetaKeepsActualUsage(t *testing.T) {
t.Errorf("actual_usage present though upstream sent none: %v", meta)
}
}

// The details line (what bodylog-exporter reads) carries actual_usage's cached_tokens as
// actual_cached_tokens next to cached_tokens -- only when the response reported one.
func TestDetailActualCachedTokens(t *testing.T) {
usage := `{"prompt_tokens":4096,"completion_tokens":128,"total_tokens":4224,"prompt_tokens_details":{"cached_tokens":2048}}`
cases := []struct {
name, extra string
want *int64
}{
{"reported", `,"actual_usage":{"prompt_tokens":4096,"prompt_tokens_details":{"cached_tokens":3072}}`, ptr(3072)},
{"reported zero", `,"actual_usage":{"prompt_tokens_details":{"cached_tokens":0}}`, ptr(0)},
{"null", `,"actual_usage":{"prompt_tokens_details":{"cached_tokens":null}}`, nil},
{"no details", `,"actual_usage":{"prompt_tokens":4096}`, nil},
{"absent", ``, nil},
}
for _, c := range cases {
body := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}],"usage":` + usage + c.extra + `}`
m := map[string]any{"status": float64(200), "resp_meta": buildRespMeta(extractResp(body))}
d := extractDetail(m)
if d.CachedTokens != 2048 {
t.Errorf("%s: cached_tokens = %d, want 2048 (usage is unaffected)", c.name, d.CachedTokens)
}
switch {
case c.want == nil && d.ActualCachedTokens != nil:
t.Errorf("%s: actual_cached_tokens = %d, want absent", c.name, *d.ActualCachedTokens)
case c.want != nil && (d.ActualCachedTokens == nil || *d.ActualCachedTokens != *c.want):
t.Errorf("%s: actual_cached_tokens = %v, want %d", c.name, d.ActualCachedTokens, *c.want)
}
line, _ := json.Marshal(d)
has := strings.Contains(string(line), `"actual_cached_tokens"`)
if has != (c.want != nil) {
t.Errorf("%s: details line has actual_cached_tokens=%v, want %v: %s", c.name, has, c.want != nil, line)
}
}
}

func ptr(v int64) *int64 { return &v }
Loading