fix(ops): show first-token latency in TTFT request details

This commit is contained in:
chihao.ou
2026-09-10 21:13:17 +08:00
parent 98d86915be
commit e9f5add97c
7 changed files with 188 additions and 17 deletions
@@ -93,6 +93,7 @@ WITH combined AS (
COALESCE(NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
ul.model AS model,
ul.duration_ms AS duration_ms,
ul.first_token_ms AS first_token_ms,
NULL::INT AS status_code,
NULL::BIGINT AS error_id,
NULL::TEXT AS phase,
@@ -117,6 +118,7 @@ WITH combined AS (
COALESCE(NULLIF(o.platform, ''), NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
o.model AS model,
o.duration_ms AS duration_ms,
o.time_to_first_token_ms AS first_token_ms,
o.status_code AS status_code,
o.id AS error_id,
o.error_phase AS phase,
@@ -152,6 +154,8 @@ WITH combined AS (
// default
case "duration_desc":
sort = "ORDER BY duration_ms DESC NULLS LAST, created_at DESC"
case "ttft_desc":
sort = "ORDER BY first_token_ms DESC NULLS LAST, created_at DESC"
default:
return nil, 0, fmt.Errorf("invalid sort")
}
@@ -166,6 +170,7 @@ SELECT
platform,
model,
duration_ms,
first_token_ms,
status_code,
error_id,
phase,
@@ -213,9 +218,10 @@ LIMIT $%d OFFSET $%d
platform sql.NullString
model sql.NullString
durationMs sql.NullInt64
statusCode sql.NullInt64
errorID sql.NullInt64
durationMs sql.NullInt64
firstTokenMs sql.NullInt64
statusCode sql.NullInt64
errorID sql.NullInt64
phase sql.NullString
severity sql.NullString
@@ -236,6 +242,7 @@ LIMIT $%d OFFSET $%d
&platform,
&model,
&durationMs,
&firstTokenMs,
&statusCode,
&errorID,
&phase,
@@ -257,12 +264,13 @@ LIMIT $%d OFFSET $%d
Platform: strings.TrimSpace(platform.String),
Model: strings.TrimSpace(model.String),
DurationMs: toIntPtr(durationMs),
StatusCode: toIntPtr(statusCode),
ErrorID: toInt64Ptr(errorID),
Phase: phase.String,
Severity: severity.String,
Message: message.String,
DurationMs: toIntPtr(durationMs),
FirstTokenMs: toIntPtr(firstTokenMs),
StatusCode: toIntPtr(statusCode),
ErrorID: toInt64Ptr(errorID),
Phase: phase.String,
Severity: severity.String,
Message: message.String,
UserID: toInt64Ptr(userID),
APIKeyID: toInt64Ptr(apiKeyID),
@@ -0,0 +1,63 @@
package repository
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestOpsRepositoryListRequestDetails_LatencySort(t *testing.T) {
for _, tc := range []struct {
name string
sort string
order string
}{
{name: "TTFT", sort: "ttft_desc", order: "first_token_ms DESC NULLS LAST, created_at DESC"},
{name: "duration", sort: "duration_desc", order: "duration_ms DESC NULLS LAST, created_at DESC"},
{name: "default", order: "created_at DESC"},
} {
t.Run(tc.name, func(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
start := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
end := start.Add(time.Hour)
filter := &service.OpsRequestDetailFilter{
StartTime: &start,
EndTime: &end,
Sort: tc.sort,
Page: 2,
PageSize: 10,
}
mock.ExpectQuery(`SELECT COUNT\(1\) FROM combined`).
WithArgs(start, end).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(13))
rows := sqlmock.NewRows([]string{
"kind", "created_at", "request_id", "platform", "model", "duration_ms", "first_token_ms",
"status_code", "error_id", "phase", "severity", "message", "user_id", "api_key_id", "account_id", "group_id", "stream",
}).
AddRow("success", start, "req-slow", "openai", "gpt-5.5", 12000, 800, nil, nil, nil, nil, nil, 1, 2, 3, 4, true).
AddRow("error", start, "req-zero", "openai", "gpt-5.5", 9000, 0, 502, 5, "upstream", "error", "failed", 1, 2, 3, 4, true).
AddRow("success", start, "req-missing", "openai", "gpt-5.5", 5000, nil, nil, nil, nil, nil, nil, 1, 2, 3, 4, false)
mock.ExpectQuery(`(?s)ul\.first_token_ms AS first_token_ms.*o\.time_to_first_token_ms AS first_token_ms.*SELECT.*duration_ms,\s+first_token_ms,.*ORDER BY ` + tc.order + `\s+LIMIT \$3 OFFSET \$4`).
WithArgs(start, end, 10, 10).
WillReturnRows(rows)
items, total, err := repo.ListRequestDetails(context.Background(), filter)
require.NoError(t, err)
require.EqualValues(t, 13, total)
require.Len(t, items, 3)
require.NotNil(t, items[0].FirstTokenMs)
require.Equal(t, 800, *items[0].FirstTokenMs)
require.Equal(t, 12000, *items[0].DurationMs)
require.NotNil(t, items[1].FirstTokenMs)
require.Zero(t, *items[1].FirstTokenMs)
require.Nil(t, items[2].FirstTokenMs)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
@@ -22,8 +22,9 @@ type OpsRequestDetail struct {
Platform string `json:"platform,omitempty"`
Model string `json:"model,omitempty"`
DurationMs *int `json:"duration_ms,omitempty"`
StatusCode *int `json:"status_code,omitempty"`
DurationMs *int `json:"duration_ms,omitempty"`
FirstTokenMs *int `json:"first_token_ms,omitempty"`
StatusCode *int `json:"status_code,omitempty"`
// When Kind == "error", ErrorID links to /admin/ops/errors/:id.
ErrorID *int64 `json:"error_id,omitempty"`
@@ -61,7 +62,7 @@ type OpsRequestDetailFilter struct {
MinDurationMs *int
MaxDurationMs *int
// Sort: created_at_desc (default) or duration_desc.
// Sort: created_at_desc (default), duration_desc or ttft_desc.
Sort string
Page int