feat(usage): 支持自定义时间统计并统一时间筛选 (#594)

* feat(usage): 统一时间区间统计与快捷筛选

* fix(usage): 精简时间筛选与趋势提示布局

* fix(usage): 保留未修改时间端点的原始时刻
This commit is contained in:
tbphp authored and GitHub committed 2026-09-08 13:33:06 +08:00
1 parent a97578fe9b
commit 175949e20a
35 files changed
+1353 -795

No files matched your search

+9 -3
View File
@@ -229,6 +229,12 @@ api_get() {
"${base_url}${path}"
}
api_get_usage() {
local to_ms
to_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
api_get "/api/usage?from_ms=$((to_ms - 86400000))&to_ms=${to_ms}"
}
api_write() {
local method="$1"
local path="$2"
@@ -281,7 +287,7 @@ test "$(
--data-binary '{"model":"task13-release-model","messages":[]}' \
"${base_url}/v1/chat/completions"
)" = "401"
api_get "/api/usage?range=24h" >"${task_tmp}/usage-empty.json"
api_get_usage >"${task_tmp}/usage-empty.json"
api_get "/api/model-prices" >"${task_tmp}/prices-empty.json"
smoke_stage="create-group"
@@ -385,7 +391,7 @@ node -e '
smoke_stage="verify-usage"
usage_complete=false
for _ in $(seq 1 80); do
api_get "/api/usage?range=24h" >"${task_tmp}/usage-first.json"
api_get_usage >"${task_tmp}/usage-first.json"
if node -e '
const fs=require("fs");
const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
@@ -432,7 +438,7 @@ test "$(
api_get "/api/groups" >"${task_tmp}/groups-second.json"
api_get "${model_price_list_path}" >"${task_tmp}/prices-second.json"
api_get "/api/usage?range=24h" >"${task_tmp}/usage-second.json"
api_get_usage >"${task_tmp}/usage-second.json"
access_list="$(api_get "/api/access-keys")"
printf '%s' "${access_list}" | node -e '
const fs=require("fs");
+3 -1
View File
@@ -299,7 +299,9 @@ try {
Authorization = "Bearer $authKey"
"Idempotency-Key" = [guid]::NewGuid().ToString().ToLowerInvariant()
}
Invoke-RestMethod "http://127.0.0.1:$port/api/usage?range=24h" -Headers $headers | Out-Null
$usageToMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$usageFromMs = $usageToMs - 86400000
Invoke-RestMethod "http://127.0.0.1:$port/api/usage?from_ms=$usageFromMs&to_ms=$usageToMs" -Headers $headers | Out-Null
Invoke-RestMethod "http://127.0.0.1:$port/api/model-prices" -Headers $headers | Out-Null
Invoke-RestMethod `
"http://127.0.0.1:$port/api/access-keys" `
+3 -1
View File
@@ -60,9 +60,11 @@ auth_key="$(cat "${data_dir}/auth.key")"
test -n "${auth_key}"
grep -F "\"version\":\"${release_version}\"" "${data_dir}/health.json"
curl -fsS "http://127.0.0.1:${port}/" | grep -F '<div id="app"></div>' >/dev/null
usage_to_ms="$(python3 -c 'import time; print(time.time_ns() // 1_000_000)')"
usage_from_ms=$((usage_to_ms - 86400000))
curl -fsS \
-H "Authorization: Bearer ${auth_key}" \
"http://127.0.0.1:${port}/api/usage?range=24h" >/dev/null
"http://127.0.0.1:${port}/api/usage?from_ms=${usage_from_ms}&to_ms=${usage_to_ms}" >/dev/null
curl -fsS \
-H "Authorization: Bearer ${auth_key}" \
"http://127.0.0.1:${port}/api/model-prices" >/dev/null
+14 -4
View File
@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
"gpt-load/internal/platform/epochms"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/requestlog"
"gpt-load/internal/storage/models"
@@ -74,16 +75,25 @@ func (s *Service) ListAccessKeyCollection(
if s.now != nil {
observedAt = s.now()
}
usageQuery, apiErr := parseUsageQuery("range="+usageRange7Days, observedAt.UnixMilli())
if apiErr != nil {
return AccessKeyCollectionResponse{}, apiErr
observedAtMS, err := safeEpochMilliseconds(observedAt)
if err != nil {
return AccessKeyCollectionResponse{}, err
}
// 密钥列表保留固定七天口径,通过明确区间与用量页联动。
fromMS, toMS, err := epochms.WindowEndingAt(observedAtMS, 6*epochms.MillisecondsPerHour, 28)
if err != nil {
return AccessKeyCollectionResponse{}, app_errors.ErrInternalServer
}
usageQuery := requestlog.UsageQuery{
FromMS: fromMS, ToMS: toMS,
Granularity: requestlog.UsageGranularityHour, BucketWidthMS: 6 * epochms.MillisecondsPerHour,
}
records, err := s.captureAccessKeyCollectionRecords(ctx, usageQuery, observedAt)
if err != nil {
return AccessKeyCollectionResponse{}, err
}
result := queryAccessKeyCollectionRecords(records, query)
result.UsageWindow = &AccessKeyUsageWindow{ObservedAtMS: observedAt.UnixMilli(), Range: usageRange7Days, FromMS: usageQuery.FromMS, ToMS: usageQuery.ToMS}
result.UsageWindow = &AccessKeyUsageWindow{ObservedAtMS: observedAtMS, Range: "7d", FromMS: usageQuery.FromMS, ToMS: usageQuery.ToMS}
return result, nil
}
@@ -31,9 +31,9 @@ func TestAccessKeyDistributionCollectionMatchesUsageAndSortsBeforePagination(t *
if err != nil {
t.Fatal(err)
}
window, apiErr := parseUsageQuery("range=7d", now.UnixMilli())
if apiErr != nil {
t.Fatal(apiErr)
window := requestlog.UsageQuery{
FromMS: time.Date(2026, 8, 31, 18, 0, 0, 0, time.UTC).UnixMilli(),
ToMS: time.Date(2026, 9, 7, 18, 0, 0, 0, time.UTC).UnixMilli(),
}
for index, row := range []models.UsageStat{
{AccessKeyID: key1.ID, BucketStartMS: window.FromMS, RequestCount: 1, SuccessCount: 1, UncachedInputTokens: 2, OutputTokens: 3, EstimatedCostNanoUSD: 100},
@@ -91,7 +91,7 @@ func TestAccessKeyDistributionCollectionMatchesUsageAndSortsBeforePagination(t *
}
})
}
recorder := performUsageRequest(engine, authTestKey, fmt.Sprintf("range=7d&access_key_id=%d", key2.ID))
recorder := performUsageRequest(engine, authTestKey, fmt.Sprintf("from_ms=%d&to_ms=%d&access_key_id=%d", window.FromMS, window.ToMS, key2.ID))
if recorder.Code != 200 {
t.Fatalf("usage = %d %s", recorder.Code, recorder.Body.String())
}
+23 -199
View File
@@ -12,7 +12,6 @@ import (
"github.com/gin-gonic/gin"
"gpt-load/internal/platform/epochms"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
"gpt-load/internal/pricing"
@@ -20,17 +19,7 @@ import (
"gpt-load/internal/usage"
)
const (
usageRange1Hour = "1h"
usageRange24Hours = "24h"
usageRange3Days = "3d"
usageRange7Days = "7d"
usageRange15Days = "15d"
usageRange30Days = "30d"
usageRangeCustom = "custom"
usageDistributionLimit = 5
usageCustomMaxMS = 30 * epochms.MillisecondsPerDay
)
const usageDistributionLimit = 5
type UsageStatReader interface {
QueryUsage(context.Context, requestlog.UsageQuery) (requestlog.UsageReport, error)
@@ -96,7 +85,6 @@ type usageCollectionHealthResponse struct {
}
type usageResponse struct {
Range string `json:"range"`
Granularity requestlog.UsageGranularity `json:"granularity"`
BucketWidthMS int64 `json:"bucket_width_ms"`
FromMS int64 `json:"from_ms"`
@@ -128,7 +116,7 @@ func (server *Server) handleUsage(c *gin.Context) {
writeServiceError(c, "usage", err)
return
}
query, apiErr := parseUsageQuery(c.Request.URL.RawQuery, observedAtMS)
query, apiErr := parseUsageQuery(c.Request.URL.RawQuery)
if apiErr != nil {
writeServiceError(c, "usage", apiErr)
return
@@ -154,14 +142,12 @@ func (server *Server) handleUsage(c *gin.Context) {
response.SuccessI18n(c, "common.success", result)
}
func parseUsageQuery(rawQuery string, observedAtMS int64) (requestlog.UsageQuery, *app_errors.APIError) {
func parseUsageQuery(rawQuery string) (requestlog.UsageQuery, *app_errors.APIError) {
values, err := url.ParseQuery(rawQuery)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
allowed := map[string]struct{}{
"range": {},
"at_ms": {},
"from_ms": {},
"to_ms": {},
"group_id": {},
@@ -176,97 +162,30 @@ func parseUsageQuery(rawQuery string, observedAtMS int64) (requestlog.UsageQuery
}
}
if err := validateSafeMilliseconds(observedAtMS); err != nil {
return requestlog.UsageQuery{}, app_errors.ErrInternalServer
}
if value, ok := singleQueryValue(values, "at_ms"); ok {
at, err := parseCanonicalSafeMilliseconds(value)
if err != nil || at > observedAtMS {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
if _, hasFrom := values["from_ms"]; hasFrom {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
observedAtMS = at
}
query := requestlog.UsageQuery{}
rangeValue := usageRange24Hours
if value, ok := singleQueryValue(values, "range"); ok {
rangeValue = value
}
fromValue, hasFrom := singleQueryValue(values, "from_ms")
toValue, hasTo := singleQueryValue(values, "to_ms")
if hasFrom != hasTo {
if !hasFrom || !hasTo {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
if hasFrom {
if _, hasRange := singleQueryValue(values, "range"); hasRange {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
fromMS, err := parseCanonicalSafeMilliseconds(fromValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
toMS, err := parseCanonicalSafeMilliseconds(toValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
if fromMS >= toMS {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
spanMS := toMS - fromMS
if spanMS > usageCustomMaxMS {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
query.FromMS = fromMS
query.ToMS = toMS
bucketWidth := epochms.MillisecondsPerDay
if spanMS <= epochms.MillisecondsPerDay {
query.Granularity = requestlog.UsageGranularityHour
bucketWidth = epochms.MillisecondsPerHour
} else {
query.Granularity = requestlog.UsageGranularityDay
}
query.BucketWidthMS = bucketWidth
if fromMS%bucketWidth != 0 || toMS%bucketWidth != 0 {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
rangeValue = ""
fromMS, err := parseCanonicalSafeMilliseconds(fromValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
if !hasFrom {
preset, ok := usageRangePreset(rangeValue)
if !ok {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
// 对齐区间包含锚点所在桶;滚动一小时必须有完整的前置时长。
earliestAtMS := int64(preset.bucketCount-1) * preset.bucketWidthMS
if rangeValue == usageRange1Hour {
earliestAtMS = epochms.MillisecondsPerHour
}
if _, anchored := singleQueryValue(values, "at_ms"); anchored && observedAtMS < earliestAtMS {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
if rangeValue == usageRange1Hour {
if observedAtMS < epochms.MillisecondsPerHour {
return requestlog.UsageQuery{}, app_errors.ErrInternalServer
}
query.FromMS = observedAtMS - epochms.MillisecondsPerHour
query.ToMS = observedAtMS
} else {
fromMS, toMS, err := epochms.WindowEndingAt(
observedAtMS,
preset.bucketWidthMS,
preset.bucketCount,
)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrInternalServer
}
query.FromMS = fromMS
query.ToMS = toMS
}
query.Granularity = preset.granularity
query.BucketWidthMS = preset.bucketWidthMS
toMS, err := parseCanonicalSafeMilliseconds(toValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
granularity, bucketWidthMS, err := requestlog.ResolveUsageTimeBucket(fromMS, toMS)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
query := requestlog.UsageQuery{
FromMS: fromMS,
ToMS: toMS,
Granularity: granularity,
BucketWidthMS: bucketWidthMS,
}
if value, ok := singleQueryValue(values, "access_key_id"); ok {
id, apiErr := parseUsageGroupID(value)
if apiErr != nil {
@@ -304,31 +223,6 @@ func parseUsageQuery(rawQuery string, observedAtMS int64) (requestlog.UsageQuery
return query, nil
}
type usagePreset struct {
bucketWidthMS int64
bucketCount int
granularity requestlog.UsageGranularity
}
func usageRangePreset(value string) (usagePreset, bool) {
switch value {
case usageRange1Hour:
return usagePreset{requestlog.UsageFiveMinuteBucketMS, 12, requestlog.UsageGranularityMinute}, true
case usageRange24Hours:
return usagePreset{epochms.MillisecondsPerHour, 24, requestlog.UsageGranularityHour}, true
case usageRange3Days:
return usagePreset{3 * epochms.MillisecondsPerHour, 24, requestlog.UsageGranularityHour}, true
case usageRange7Days:
return usagePreset{6 * epochms.MillisecondsPerHour, 28, requestlog.UsageGranularityHour}, true
case usageRange15Days:
return usagePreset{12 * epochms.MillisecondsPerHour, 30, requestlog.UsageGranularityHour}, true
case usageRange30Days:
return usagePreset{epochms.MillisecondsPerDay, 30, requestlog.UsageGranularityDay}, true
default:
return usagePreset{}, false
}
}
func validUsageModel(value string) bool {
if !utf8.ValidString(value) || len(value) < 1 || len(value) > 255 ||
strings.TrimSpace(value) != value {
@@ -377,11 +271,7 @@ func (service *Service) mapUsageResponse(
stats.WriteFailureTotal > uint64(maxSafeInteger) {
return usageResponse{}, fmt.Errorf("map usage collection health: unsafe counter")
}
bucketWidthMS, err := usageResponseBucketWidth(query)
if err != nil {
return usageResponse{}, err
}
rangeValue, err := usageResponseRange(query, bucketWidthMS)
granularity, bucketWidthMS, err := requestlog.ResolveUsageTimeBucket(query.FromMS, query.ToMS)
if err != nil {
return usageResponse{}, err
}
@@ -399,8 +289,7 @@ func (service *Service) mapUsageResponse(
return usageResponse{}, fmt.Errorf("map usage observed_at_ms: %w", err)
}
result := usageResponse{
Range: rangeValue,
Granularity: query.Granularity,
Granularity: granularity,
BucketWidthMS: bucketWidthMS,
FromMS: query.FromMS,
ToMS: query.ToMS,
@@ -626,71 +515,6 @@ func validUsageDistributionModel(value string) bool {
return true
}
func usageResponseBucketWidth(query requestlog.UsageQuery) (int64, error) {
bucketWidthMS := query.BucketWidthMS
if query.Granularity == requestlog.UsageGranularityMinute {
if bucketWidthMS != requestlog.UsageFiveMinuteBucketMS ||
query.ToMS-query.FromMS != epochms.MillisecondsPerHour {
return 0, fmt.Errorf("map usage response: invalid minute window")
}
return bucketWidthMS, nil
}
if bucketWidthMS == 0 {
switch query.Granularity {
case requestlog.UsageGranularityHour:
bucketWidthMS = epochms.MillisecondsPerHour
case requestlog.UsageGranularityDay:
bucketWidthMS = epochms.MillisecondsPerDay
default:
return 0, fmt.Errorf("map usage response: invalid granularity")
}
}
if bucketWidthMS < epochms.MillisecondsPerHour ||
bucketWidthMS > epochms.MillisecondsPerDay ||
bucketWidthMS%epochms.MillisecondsPerHour != 0 {
return 0, fmt.Errorf("map usage response: invalid bucket width")
}
if query.Granularity == requestlog.UsageGranularityHour &&
bucketWidthMS >= epochms.MillisecondsPerDay {
return 0, fmt.Errorf("map usage response: invalid hourly bucket width")
}
if query.Granularity == requestlog.UsageGranularityDay &&
bucketWidthMS != epochms.MillisecondsPerDay {
return 0, fmt.Errorf("map usage response: invalid daily bucket width")
}
return bucketWidthMS, nil
}
func usageResponseRange(query requestlog.UsageQuery, bucketWidthMS int64) (string, error) {
duration := query.ToMS - query.FromMS
if query.Granularity != requestlog.UsageGranularityMinute &&
query.Granularity != requestlog.UsageGranularityHour &&
query.Granularity != requestlog.UsageGranularityDay {
return "", fmt.Errorf("map usage response: invalid granularity")
}
// 显式指定一个完整小时的自定义查询继续返回原有范围标签。
if query.Granularity == requestlog.UsageGranularityHour &&
bucketWidthMS == epochms.MillisecondsPerHour && duration == epochms.MillisecondsPerHour {
return usageRange1Hour, nil
}
for _, rangeValue := range []string{
usageRange1Hour,
usageRange24Hours,
usageRange3Days,
usageRange7Days,
usageRange15Days,
usageRange30Days,
} {
preset, _ := usageRangePreset(rangeValue)
if query.Granularity == preset.granularity &&
bucketWidthMS == preset.bucketWidthMS &&
duration == int64(preset.bucketCount)*preset.bucketWidthMS {
return rangeValue, nil
}
}
return usageRangeCustom, nil
}
func mapUsageAggregate(source requestlog.UsageAggregate) (usageAggregateResponse, error) {
values := []int64{
source.RequestCount, source.SuccessCount, source.FailureCount,
+11 -12
View File
@@ -66,17 +66,16 @@ func TestUsageAPIRollingHourReadsLogsAndScopesAccessKey(t *testing.T) {
query string
wantCount int64
wantCost string
wantRange string
wantGrain requestlog.UsageGranularity
}{
{"management frozen hour", "test-auth-key", fmt.Sprintf("range=1h&at_ms=%d", now.Add(-time.Minute).UnixMilli()), 2, "1400", "1h", requestlog.UsageGranularityMinute},
{"management rolling hour", "test-auth-key", "range=1h", 3, "2100", "1h", requestlog.UsageGranularityMinute},
{"access key rolling hour", key.Key, "range=1h", 2, "1400", "1h", requestlog.UsageGranularityMinute},
{"management filters access key", "test-auth-key", fmt.Sprintf("range=1h&access_key_id=%d&group_id=7", key.ID), 2, "1400", "1h", requestlog.UsageGranularityMinute},
{"daily range retains hourly source", "test-auth-key", "range=24h", 99, "0", "24h", requestlog.UsageGranularityHour},
{"custom hour retains hourly source and metadata", "test-auth-key",
{"management frozen hour", "test-auth-key", usageTestTimeQuery(from.Add(-time.Minute), now.Add(-time.Minute)), 2, "1400", requestlog.UsageGranularityMinute},
{"management rolling hour", "test-auth-key", usageTestTimeQuery(from, now), 3, "2100", requestlog.UsageGranularityMinute},
{"access key rolling hour", key.Key, usageTestTimeQuery(from, now), 2, "1400", requestlog.UsageGranularityMinute},
{"management filters access key", "test-auth-key", usageTestTimeQuery(from, now) + fmt.Sprintf("&access_key_id=%d&group_id=7", key.ID), 2, "1400", requestlog.UsageGranularityMinute},
{"daily range combines hourly and boundary sources", "test-auth-key", usageTestTimeQuery(now.Add(-24*time.Hour), now), 101, "1400", requestlog.UsageGranularityHour},
{"aligned hour uses logs and minute buckets", "test-auth-key",
fmt.Sprintf("from_ms=%d&to_ms=%d", from.Truncate(time.Hour).UnixMilli(), from.Truncate(time.Hour).Add(time.Hour).UnixMilli()),
99, "0", "1h", requestlog.UsageGranularityHour},
2, "1400", requestlog.UsageGranularityMinute},
} {
t.Run(test.name, func(t *testing.T) {
recorder := performUsageRequest(engine, test.auth, test.query)
@@ -90,13 +89,13 @@ func TestUsageAPIRollingHourReadsLogsAndScopesAccessKey(t *testing.T) {
t.Fatalf("decode usage response: %v", err)
}
data := envelope.Data
if data.Range != test.wantRange || data.Granularity != test.wantGrain {
t.Fatalf("usage range/granularity = %s/%s, want %s/%s", data.Range, data.Granularity, test.wantRange, test.wantGrain)
if data.Granularity != test.wantGrain {
t.Fatalf("usage granularity = %s, want %s", data.Granularity, test.wantGrain)
}
if data.Summary.RequestCount != test.wantCount || data.Summary.EstimatedCostNanoUSD != test.wantCost {
t.Fatalf("usage summary = %+v", data.Summary)
}
if test.query == "range=1h" {
if test.query == usageTestTimeQuery(from, now) {
if len(data.Series) != 2 || data.Series[0].BucketStartMS != from.UnixMilli() ||
data.Series[1].BucketEndMS != now.UnixMilli() || data.Granularity != "minute" {
t.Fatalf("rolling usage series = %+v", data.Series)
@@ -121,7 +120,7 @@ func TestUsageAPIRollingHourReadsLogsAndScopesAccessKey(t *testing.T) {
})
}
for _, id := range []uint{key.ID, key.ID + 1} {
recorder := performUsageRequest(engine, key.Key, fmt.Sprintf("range=1h&access_key_id=%d", id))
recorder := performUsageRequest(engine, key.Key, usageTestTimeQuery(from, now)+fmt.Sprintf("&access_key_id=%d", id))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("access key cannot select a scope: %d %s", recorder.Code, recorder.Body.String())
}
+34 -243
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -31,7 +32,7 @@ func TestUsageAPIRouteUsesManagementAuthentication(t *testing.T) {
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
request := httptest.NewRequest(http.MethodGet, "/api/usage", nil)
request := httptest.NewRequest(http.MethodGet, "/api/usage?"+usageTestDayQuery, nil)
request.Header.Set("Authorization", "Bearer test-auth-key")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
@@ -40,221 +41,7 @@ func TestUsageAPIRouteUsesManagementAuthentication(t *testing.T) {
}
}
func TestParseUsageQueryUsesRangeSpecificWindows(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawQuery string
observedAt time.Time
wantFrom time.Time
wantTo time.Time
granularity requestlog.UsageGranularity
bucketWidth int64
}{
{
name: "1 hour uses the last sixty minutes across the hour boundary",
rawQuery: "range=1h",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789_000_000, time.UTC),
wantFrom: time.Date(2026, time.July, 27, 11, 34, 56, 789_000_000, time.UTC),
wantTo: time.Date(2026, time.July, 27, 12, 34, 56, 789_000_000, time.UTC),
granularity: requestlog.UsageGranularityMinute,
bucketWidth: int64(5 * time.Minute / time.Millisecond),
},
{
name: "1 hour at an exact hour includes the preceding hour",
rawQuery: "range=1h",
observedAt: time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC),
wantFrom: time.Date(2026, time.July, 27, 11, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityMinute,
bucketWidth: int64(5 * time.Minute / time.Millisecond),
},
{
name: "custom one hour retains hourly aggregation",
rawQuery: "from_ms=1785146400000&to_ms=1785150000000",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.UnixMilli(1785146400000),
wantTo: time.UnixMilli(1785150000000),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(time.Hour / time.Millisecond),
},
{
name: "24 hours crosses the local and UTC day at an exact hour boundary",
rawQuery: "range=24h",
observedAt: time.Date(2026, time.July, 27, 0, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60)),
wantFrom: time.Date(2026, time.July, 25, 17, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 26, 17, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(time.Hour / time.Millisecond),
},
{
name: "30 days crosses the local and UTC day at exact day boundaries",
rawQuery: "range=30d",
observedAt: time.Date(2026, time.July, 26, 20, 34, 56, 789, time.FixedZone("UTC-7", -7*60*60)),
wantFrom: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityDay,
bucketWidth: int64(24 * time.Hour / time.Millisecond),
},
{
name: "3 days uses three hour buckets",
rawQuery: "range=3d",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.Date(2026, time.July, 24, 15, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 27, 15, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(3 * time.Hour / time.Millisecond),
},
{
name: "7 days uses six hour buckets",
rawQuery: "range=7d",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.Date(2026, time.July, 20, 18, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 27, 18, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(6 * time.Hour / time.Millisecond),
},
{
name: "15 days uses twelve hour buckets",
rawQuery: "range=15d",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.Date(2026, time.July, 13, 0, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(12 * time.Hour / time.Millisecond),
},
{
name: "custom 24 hours accepts exact UTC hour boundaries",
rawQuery: "from_ms=1785042000000&to_ms=1785128400000",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityHour,
bucketWidth: int64(time.Hour / time.Millisecond),
},
{
name: "custom 30 days accepts exact UTC day boundaries",
rawQuery: "from_ms=1782604800000&to_ms=1785196800000",
observedAt: time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC),
wantFrom: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC),
wantTo: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC),
granularity: requestlog.UsageGranularityDay,
bucketWidth: int64(24 * time.Hour / time.Millisecond),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
query, apiErr := parseUsageQuery(test.rawQuery, test.observedAt.UnixMilli())
if apiErr != nil {
t.Fatalf("parseUsageQuery() error = %v", apiErr)
}
if query.FromMS != test.wantFrom.UnixMilli() ||
query.ToMS != test.wantTo.UnixMilli() ||
query.Granularity != test.granularity ||
query.BucketWidthMS != test.bucketWidth {
t.Fatalf(
"parseUsageQuery() window = %d to %d (%s, %dms), want %d to %d (%s, %dms)",
query.FromMS,
query.ToMS,
query.Granularity,
query.BucketWidthMS,
test.wantFrom.UnixMilli(),
test.wantTo.UnixMilli(),
test.granularity,
test.bucketWidth,
)
}
})
}
}
func TestUsageAPIReturnsExactPresetRange(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.July, 27, 12, 34, 56, 789, time.UTC)
tests := []struct {
rangeValue string
bucketWidthMS int64
}{
{rangeValue: "1h", bucketWidthMS: int64(5 * time.Minute / time.Millisecond)},
{rangeValue: "24h", bucketWidthMS: int64(time.Hour / time.Millisecond)},
{rangeValue: "3d", bucketWidthMS: int64(3 * time.Hour / time.Millisecond)},
{rangeValue: "7d", bucketWidthMS: int64(6 * time.Hour / time.Millisecond)},
{rangeValue: "15d", bucketWidthMS: int64(12 * time.Hour / time.Millisecond)},
{rangeValue: "30d", bucketWidthMS: int64(24 * time.Hour / time.Millisecond)},
}
for _, test := range tests {
t.Run(test.rangeValue, func(t *testing.T) {
engine, _ := newUsageTestEngine(t, now, &recordingUsageStatReader{})
recorder := performUsageRequest(engine, "test-auth-key", "range="+test.rangeValue)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Data struct {
Range string `json:"range"`
BucketWidthMS int64 `json:"bucket_width_ms"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Data.Range != test.rangeValue ||
envelope.Data.BucketWidthMS != test.bucketWidthMS {
t.Fatalf("range/bucket width = %q/%d, want %q/%d", envelope.Data.Range, envelope.Data.BucketWidthMS, test.rangeValue, test.bucketWidthMS)
}
})
}
}
func TestUsageAPIValidatesAnchorRangeBoundaries(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 8, 0, 0, 0, 0, time.UTC)
tests := []struct {
rangeValue string
firstAnchor time.Duration
span time.Duration
}{
{"", 23 * time.Hour, 24 * time.Hour},
{"1h", time.Hour, time.Hour},
{"24h", 23 * time.Hour, 24 * time.Hour},
{"3d", 69 * time.Hour, 72 * time.Hour},
{"7d", 162 * time.Hour, 168 * time.Hour},
{"15d", 348 * time.Hour, 360 * time.Hour},
{"30d", 29 * 24 * time.Hour, 30 * 24 * time.Hour},
}
for _, test := range tests {
t.Run(test.rangeValue, func(t *testing.T) {
reader := &recordingUsageStatReader{}
engine, _ := newUsageTestEngine(t, now, reader)
params := url.Values{}
if test.rangeValue != "" {
params.Set("range", test.rangeValue)
}
for _, anchor := range []int64{0, 1, test.firstAnchor.Milliseconds() - 1} {
t.Run(strconv.FormatInt(anchor, 10), func(t *testing.T) {
params.Set("at_ms", strconv.FormatInt(anchor, 10))
recorder := performUsageRequest(engine, "test-auth-key", params.Encode())
assertUsageErrorCode(t, recorder, "BAD_REQUEST")
})
}
if len(reader.queries) != 0 {
t.Fatalf("invalid anchor reached usage reader: %d calls", len(reader.queries))
}
params.Set("at_ms", strconv.FormatInt(test.firstAnchor.Milliseconds(), 10))
recorder := performUsageRequest(engine, "test-auth-key", params.Encode())
if recorder.Code != http.StatusOK || len(reader.queries) != 1 {
t.Fatalf("earliest valid anchor = %d %s, reader calls=%d", recorder.Code, recorder.Body.String(), len(reader.queries))
}
query := reader.queries[0]
if query.FromMS != 0 || query.ToMS != test.span.Milliseconds() {
t.Fatalf("earliest window = [%d, %d), want [0, %d)", query.FromMS, query.ToMS, test.span.Milliseconds())
}
})
}
}
func TestUsageAPIRollingHourRefreshUsesNewObservationAndPreservesFilters(t *testing.T) {
func TestUsageAPIUsesSuppliedWindowAndPreservesFilters(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 7, 14, 5, 23, 123_000_000, time.UTC)
reader := &recordingUsageStatReader{}
@@ -263,7 +50,7 @@ func TestUsageAPIRollingHourRefreshUsesNewObservationAndPreservesFilters(t *test
for _, observation := range []time.Time{now, now.Add(2 * time.Minute)} {
now = observation
recorder := performUsageRequest(engine, "test-auth-key",
"range=1h&group_id=7&channel_id=openai&credential_id=11&upstream_model=usage-model")
usageTestTimeQuery(now.Add(-time.Hour), now)+"&group_id=7&channel_id=openai&credential_id=11&upstream_model=usage-model")
if recorder.Code != http.StatusOK {
t.Fatalf("rolling usage response = %d %s", recorder.Code, recorder.Body.String())
}
@@ -274,7 +61,7 @@ func TestUsageAPIRollingHourRefreshUsesNewObservationAndPreservesFilters(t *test
t.Fatalf("decode rolling usage response: %v", err)
}
data := envelope.Data
if data.Range != "1h" || data.Granularity != "minute" ||
if data.Granularity != "minute" ||
data.BucketWidthMS != int64(5*time.Minute/time.Millisecond) ||
data.FromMS != now.Add(-time.Hour).UnixMilli() || data.ToMS != now.UnixMilli() ||
data.ObservedAtMS != now.UnixMilli() {
@@ -324,7 +111,7 @@ func TestUsageAPIReturnsDistributionWithoutCredentialIdentity(t *testing.T) {
recorder := performUsageRequest(
engine,
"test-auth-key",
"",
usageTestDayQuery,
)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
@@ -374,7 +161,7 @@ func TestUsageAPIReturnsDistributionWithoutCredentialIdentity(t *testing.T) {
}
}
func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing.T) {
func TestUsageAPIReturnsExplicitWindowAndZeroArrays(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.July, 27, 12, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60))
reader := &recordingUsageStatReader{}
@@ -385,7 +172,7 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing
2026, time.July, 27, 3, 0, 0, 0, time.UTC,
)
recorder := performUsageRequest(engine, "test-auth-key", "")
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
@@ -402,7 +189,6 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing
var envelope struct {
Code int `json:"code"`
Data struct {
Range string `json:"range"`
Granularity requestlog.UsageGranularity `json:"granularity"`
FromMS int64 `json:"from_ms"`
ToMS int64 `json:"to_ms"`
@@ -427,7 +213,7 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.Range != "24h" ||
if envelope.Code != 0 ||
envelope.Data.Granularity != requestlog.UsageGranularityHour ||
envelope.Data.FromMS != time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC).UnixMilli() ||
envelope.Data.ToMS != time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC).UnixMilli() ||
@@ -450,7 +236,7 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing
t.Fatalf("decode raw response: %v", err)
}
for _, forbidden := range []string{
"filters", "request_log", "timezone", "from", "to", "observed_at",
"range", "filters", "request_log", "timezone", "from", "to", "observed_at",
"estimated_cost" + "_usd",
} {
if _, exists := rawEnvelope.Data[forbidden]; exists {
@@ -465,7 +251,7 @@ func TestUsageAPIReturnsAllDistributionViewsInOneResponse(t *testing.T) {
reader := &recordingUsageStatReader{}
engine, _ := newUsageTestEngine(t, now, reader)
recorder := performUsageRequest(engine, "test-auth-key", "range=24h")
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
@@ -554,7 +340,7 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) {
recorder := performUsageRequest(
engine,
"test-auth-key",
"range=30d&group_id=9&channel_id=openai&credential_id=13&upstream_model=upstream-model",
"from_ms=1782604800000&to_ms=1785196800000&group_id=9&channel_id=openai&credential_id=13&upstream_model=upstream-model",
)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
@@ -574,7 +360,6 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) {
}
var envelope struct {
Data struct {
Range string `json:"range"`
Granularity requestlog.UsageGranularity `json:"granularity"`
FromMS int64 `json:"from_ms"`
ToMS int64 `json:"to_ms"`
@@ -609,8 +394,7 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Data.Range != "30d" ||
envelope.Data.Granularity != requestlog.UsageGranularityDay ||
if envelope.Data.Granularity != requestlog.UsageGranularityDay ||
envelope.Data.FromMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
envelope.Data.ToMS != time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
envelope.Data.Summary.TotalTokens != 9 ||
@@ -652,7 +436,7 @@ func TestUsageAPIValidatesModelAsUTF8BytesWithoutBoundaryWhitespaceOrControls(t
"model variant",
}
for _, model := range validModels {
recorder := performUsageRequest(engine, "test-auth-key", "upstream_model="+url.QueryEscape(model))
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&upstream_model="+url.QueryEscape(model))
if recorder.Code != http.StatusOK {
t.Fatalf("valid model %q response = %d %s", model, recorder.Code, recorder.Body.String())
}
@@ -675,7 +459,7 @@ func TestUsageAPIValidatesModelAsUTF8BytesWithoutBoundaryWhitespaceOrControls(t
}
for _, test := range invalidModels {
t.Run(test.name, func(t *testing.T) {
recorder := performUsageRequest(engine, "test-auth-key", test.query)
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&"+test.query)
assertUsageErrorCode(t, recorder, "VALIDATION_FAILED")
})
}
@@ -707,10 +491,7 @@ func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) {
{query: "from_ms=01&to_ms=2", code: "BAD_REQUEST"},
{query: "from_ms=-1&to_ms=2", code: "BAD_REQUEST"},
{query: "from_ms=1&to_ms=1", code: "VALIDATION_FAILED"},
{query: "from_ms=1&to_ms=3600001", code: "VALIDATION_FAILED"},
{query: "from_ms=1&to_ms=90000001", code: "VALIDATION_FAILED"},
{query: "from_ms=0&to_ms=2678400000", code: "VALIDATION_FAILED"},
{query: "range=24h&from_ms=1&to_ms=2", code: "VALIDATION_FAILED"},
{query: "range=24h&from_ms=1&to_ms=2", code: "BAD_REQUEST"},
{query: "group_id=0", code: "VALIDATION_FAILED"},
{query: "group_id=01", code: "BAD_REQUEST"},
{query: "group_id=%2B1", code: "BAD_REQUEST"},
@@ -740,7 +521,11 @@ func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) {
}
for _, test := range tests {
t.Run(test.query, func(t *testing.T) {
recorder := performUsageRequest(engine, "test-auth-key", test.query)
query := test.query
if !strings.Contains(query, "from_ms") && !strings.Contains(query, "to_ms") {
query = usageTestDayQuery + "&" + query
}
recorder := performUsageRequest(engine, "test-auth-key", query)
assertUsageErrorCode(t, recorder, test.code)
})
}
@@ -863,7 +648,7 @@ func TestUsageAPIRejectsUnsafeAggregateAndKeepsErrorsSecret(t *testing.T) {
Summary: requestlog.UsageAggregate{RequestCount: 9_007_199_254_740_992},
}}
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
recorder := performUsageRequest(engine, "test-auth-key", "")
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
if recorder.Code != http.StatusInternalServerError ||
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
strings.Contains(recorder.Body.String(), "unsafe") {
@@ -872,7 +657,7 @@ func TestUsageAPIRejectsUnsafeAggregateAndKeepsErrorsSecret(t *testing.T) {
reader.err = errors.New("usage database secret")
reader.report = requestlog.UsageReport{}
recorder = performUsageRequest(engine, "test-auth-key", "")
recorder = performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "usage database secret") {
t.Fatalf("reader error response = %d %s", recorder.Code, recorder.Body.String())
}
@@ -895,7 +680,7 @@ func TestUsageAPIRejectsUnsafeProcessStatsWithoutLeakingCause(t *testing.T) {
&recordingUsageStatReader{},
)
test.mutate(&fixture.requestLogStats.value)
recorder := performUsageRequest(engine, "test-auth-key", "")
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
if recorder.Code != http.StatusInternalServerError ||
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
strings.Contains(strings.ToLower(recorder.Body.String()), "queue") ||
@@ -926,7 +711,7 @@ func TestUsageAPIExcludesLegacyZeroAttemptAggregateFromSQLite(t *testing.T) {
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
recorder := performUsageRequest(engine, "test-auth-key", "")
recorder := performUsageRequest(engine, "test-auth-key", usageTestTimeQuery(now.Add(-24*time.Hour), now))
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
}
@@ -967,7 +752,7 @@ func TestUsageAPIAcceptsMaximumSafeCanonicalGroupID(t *testing.T) {
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
reader,
)
recorder := performUsageRequest(engine, "test-auth-key", "group_id=9007199254740991")
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&group_id=9007199254740991")
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
}
@@ -1011,7 +796,7 @@ func TestUsageAPIBindsAccessKeyScopeAndRedactsProcessHealth(t *testing.T) {
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
recorder := performUsageRequest(engine, created.Key, "range=7d&upstream_model=allowed-model")
recorder := performUsageRequest(engine, created.Key, usageTestTimeQuery(now.AddDate(0, 0, -7), now)+"&upstream_model=allowed-model")
if recorder.Code != http.StatusOK {
t.Fatalf("AccessKey usage response = %d %s, want 200", recorder.Code, recorder.Body.String())
}
@@ -1049,7 +834,7 @@ func TestUsageAPIBindsAccessKeyScopeAndRedactsProcessHealth(t *testing.T) {
}
for _, filter := range []string{"group_id=1", "channel_id=openai", "credential_id=101"} {
forbidden := performUsageRequest(engine, created.Key, filter)
forbidden := performUsageRequest(engine, created.Key, usageTestDayQuery+"&"+filter)
if forbidden.Code != http.StatusBadRequest || len(reader.queries) != 1 {
t.Fatalf(
"AccessKey internal filter %q = %d %s, calls=%d, want 400/no query",
@@ -1168,6 +953,12 @@ func newUsageTestEngine(
return engine, fixture
}
const usageTestDayQuery = "from_ms=1785042000000&to_ms=1785128400000"
func usageTestTimeQuery(from, to time.Time) string {
return fmt.Sprintf("from_ms=%d&to_ms=%d", from.UnixMilli(), to.UnixMilli())
}
func performUsageRequest(engine *gin.Engine, authKey, query string) *httptest.ResponseRecorder {
target := "/api/usage"
if query != "" {
+93
View File
@@ -0,0 +1,93 @@
package control
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
)
func TestUsageAPIExplicitTimeRange(t *testing.T) {
t.Parallel()
const hourMS int64 = 60 * 60 * 1000
const dayMS = 24 * hourMS
from := time.Date(2026, time.September, 8, 9, 15, 7, 123_000_000, time.UTC).UnixMilli()
for _, test := range []struct {
name string
span int64
width int64
grain string
}{
{"one millisecond", 1, 5 * 60 * 1000, "minute"},
{"one hour", hourMS, 5 * 60 * 1000, "minute"},
{"over one hour", hourMS + 1, hourMS, "hour"},
{"six hours", 6 * hourMS, hourMS, "hour"},
{"one day", dayMS, hourMS, "hour"},
{"three days", 3 * dayMS, 3 * hourMS, "hour"},
{"seven days", 7 * dayMS, 6 * hourMS, "hour"},
{"fifteen days", 15 * dayMS, 12 * hourMS, "hour"},
{"thirty days", 30 * dayMS, dayMS, "day"},
{"over thirty days", 30*dayMS + 1, 2 * dayMS, "day"},
{"one year", 365 * dayMS, 13 * dayMS, "day"},
{"future four hundred years", 400 * 365 * dayMS, 4867 * dayMS, "day"},
} {
t.Run(test.name, func(t *testing.T) {
reader := &recordingUsageStatReader{}
engine, _ := newUsageTestEngine(t, time.UnixMilli(from), reader)
query := fmt.Sprintf("from_ms=%d&to_ms=%d&group_id=7&channel_id=openai&credential_id=11&upstream_model=usage-model", from, from+test.span)
recorder := performUsageRequest(engine, "test-auth-key", query)
if recorder.Code != http.StatusOK {
t.Fatalf("explicit interval response = %d %s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if _, exists := envelope.Data["range"]; exists {
t.Fatal("explicit interval response must not expose a preset range")
}
var data usageResponse
rawData, err := json.Marshal(envelope.Data)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(rawData, &data); err != nil {
t.Fatal(err)
}
if data.FromMS != from || data.ToMS != from+test.span ||
data.BucketWidthMS != test.width || string(data.Granularity) != test.grain {
t.Fatalf("explicit interval changed: %+v", data)
}
if len(reader.queries) != 1 {
t.Fatalf("reader calls = %d, want 1", len(reader.queries))
}
actual := reader.queries[0]
if actual.FromMS != from || actual.ToMS != from+test.span ||
actual.GroupID == nil || *actual.GroupID != 7 || actual.ChannelID != "openai" ||
actual.CredentialID == nil || *actual.CredentialID != 11 || actual.UpstreamModel != "usage-model" {
t.Fatalf("reader interval or filters changed: %+v", actual)
}
})
}
}
func TestUsageAPIRequiresExplicitTimeRange(t *testing.T) {
t.Parallel()
for _, query := range []string{
"", "from_ms=0", "to_ms=3600000", "range=1h", "range=24h&from_ms=0&to_ms=3600000",
"from_ms=0&to_ms=0", "from_ms=2&to_ms=1", "from_ms=-1&to_ms=1",
"from_ms=0&to_ms=9007199254740992", "from_ms=0&from_ms=1&to_ms=3600000",
} {
t.Run(query, func(t *testing.T) {
reader := &recordingUsageStatReader{}
engine, _ := newUsageTestEngine(t, time.Now(), reader)
recorder := performUsageRequest(engine, "test-auth-key", query)
if recorder.Code != http.StatusBadRequest || len(reader.queries) != 0 {
t.Fatalf("invalid interval response = %d %s, reader calls %d", recorder.Code, recorder.Body.String(), len(reader.queries))
}
})
}
}
+3 -3
View File
@@ -202,7 +202,7 @@ func TestQueryUsageFiltersAndProjectsChannelCredential(t *testing.T) {
credentialID := uint(11)
report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
ChannelID: channel.OpenAI,
CredentialID: &credentialID,
@@ -237,7 +237,7 @@ func TestAccessScopedUsageDistributionCollapsesHiddenRoutesByModel(t *testing.T)
accessKeyID := uint(41)
report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
AccessKeyID: &accessKeyID,
SelfScoped: true,
@@ -268,7 +268,7 @@ func TestAccessScopedUsageReturnsOnlyModelDistributions(t *testing.T) {
accessKeyID := uint(41)
report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
AccessKeyID: &accessKeyID,
SelfScoped: true,
+4 -3
View File
@@ -185,9 +185,10 @@ const (
type UsageQuery struct {
// SelfScoped 控制只读用户视图,与管理员的密钥筛选独立。
SelfScoped bool
FromMS int64
ToMS int64
SelfScoped bool
FromMS int64
ToMS int64
// 供管理 API 描述时间桶;QueryUsage 始终从 FromMS/ToMS 推导,不接受覆盖。
Granularity UsageGranularity
BucketWidthMS int64
AccessKeyID *uint
+194
View File
@@ -0,0 +1,194 @@
package requestlog
import (
"fmt"
"os"
"strings"
"testing"
"time"
"gorm.io/gorm"
"gpt-load/internal/platform/config"
"gpt-load/internal/storage"
"gpt-load/internal/storage/models"
)
// 与现有数据库合同共用入口,在真实驱动上验证混合查询和多天分桶 SQL。
func TestExternalDatabaseUsageExactWindow(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("GPT_LOAD_DATABASE_TEST_DSN"))
if dsn == "" {
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
}
db, err := storage.OpenWithSource(dsn, config.DatabaseSourceExternal)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close usage database: %v", err)
}
})
if err := storage.AutoMigrate(db); err != nil {
t.Fatal(err)
}
unique := uint64(time.Now().UnixNano()) & 0xffffffffff
credentialID := uint(unique%1_000_000_000) + 1_000_000
groups := make([]models.Group, 6)
keys := make([]models.AccessKey, 6)
for i := range groups {
name := fmt.Sprintf("usage-window-%x-%d", unique, i)
groups[i] = models.Group{Name: name, ChannelID: "openai", Params: models.JSON(`{}`), Models: models.JSON(`[]`), Enabled: true}
keys[i] = models.AccessKey{Name: name, KeyValue: "encrypted-test-key", KeyHash: name, KeySuffix: "abcd", Status: "active", Filters: models.JSON(`{}`)}
}
var requestIDs []string
t.Cleanup(func() {
for _, operation := range []struct {
model any
query string
value any
}{
{&models.UsageAggregationJournal{}, "request_id IN ?", requestIDs},
{&models.RequestLog{}, "id IN ?", requestIDs},
{&models.UsageStat{}, "credential_id = ?", credentialID},
} {
if err := db.Where(operation.query, operation.value).Delete(operation.model).Error; err != nil {
t.Errorf("cleanup usage fixtures: %v", err)
}
}
for i := range groups {
if keys[i].ID != 0 {
if err := db.Delete(&keys[i]).Error; err != nil {
t.Errorf("cleanup usage access key: %v", err)
}
}
if groups[i].ID != 0 {
if err := db.Delete(&groups[i]).Error; err != nil {
t.Errorf("cleanup usage group: %v", err)
}
}
}
})
for i := range groups {
for _, row := range []any{&groups[i], &keys[i]} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
base := time.Date(2026, time.September, 8, 9, 0, 0, 0, time.UTC)
from, to := base.Add(15*time.Minute), base.Add(6*time.Hour+40*time.Minute)
var rows []models.RequestLog
appendRow := func(completedAt time.Time, dimension int) {
id := fmt.Sprintf("00000000-0000-4000-8000-%010x%02x", unique, len(rows))
row := aggregationRow(id, completedAt, groups[dimension].ID, fmt.Sprintf("model-%d", dimension))
row.AccessKeyID, row.CredentialID, row.ChannelID = keys[dimension].ID, credentialID, "openai"
rows, requestIDs = append(rows, row), append(requestIDs, id)
}
for dimension := range groups {
for range dimension + 1 {
for _, offset := range []time.Duration{30 * time.Minute, 3*time.Hour + 30*time.Minute, 6*time.Hour + 30*time.Minute} {
appendRow(base.Add(offset), dimension)
}
}
}
for _, completedAt := range []time.Time{from.Add(-time.Millisecond), from, base.Add(time.Hour), base.Add(6 * time.Hour), to.Add(-time.Millisecond), to} {
appendRow(completedAt, 0)
}
if err := (&gormBatchWriter{db: db}).WriteBatch(t.Context(), rows); err != nil {
t.Fatal(err)
}
service := newRequestLogTestService(db)
query := UsageQuery{FromMS: from.UnixMilli(), ToMS: to.UnixMilli(), CredentialID: &credentialID}
assertCount := func(query UsageQuery, want int64) UsageReport {
t.Helper()
report, err := service.QueryUsage(t.Context(), query)
if err != nil {
t.Fatal(err)
}
if report.Summary.RequestCount != want || report.Summary.UncachedInputTokens != want ||
report.Summary.OutputTokens != 2*want || report.Summary.EstimatedCostNanoUSD != 250_000_000*want {
t.Fatalf("usage summary = %+v, want %d frozen requests", report.Summary, want)
}
assertMinuteUsageReportTotals(t, report)
return report
}
report := assertCount(query, 67)
for _, point := range report.Series {
bucket := point.BucketStartMS - point.BucketStartMS%time.Hour.Milliseconds()
if point.BucketStartMS != max(bucket, query.FromMS) || point.BucketEndMS != min(bucket+time.Hour.Milliseconds(), query.ToMS) {
t.Fatalf("mixed series bucket = %+v, want exact clipped hour", point)
}
}
for _, distributions := range []map[UsageDistributionMetric]UsageDistribution{report.Distributions.Group, report.Distributions.Model, report.Distributions.AccessKey} {
for _, distribution := range distributions {
if len(distribution.Items) != 5 || distribution.Items[0].RequestCount != 18 ||
distribution.Other == nil || distribution.Other.RequestCount != 6 {
t.Fatalf("global Top5 = %+v, want largest 18 and other 6", distribution)
}
}
}
short := query
short.ToMS = from.Add(time.Hour).UnixMilli()
assertCount(short, 23)
// 中间日志已清理时仍只取小时聚合;首尾日志清理后直接返回剩余数据。
fullFrom, fullTo := base.Add(time.Hour).UnixMilli(), to.Truncate(time.Hour).UnixMilli()
deleteLogs := func(scope *gorm.DB) {
t.Helper()
if err := scope.Where("credential_id = ?", credentialID).Delete(&models.RequestLog{}).Error; err != nil {
t.Fatal(err)
}
}
deleteLogs(db.Where("completed_at_ms >= ? AND completed_at_ms < ?", fullFrom, fullTo))
assertCount(query, 67)
deleteLogs(db)
assertCount(query, 22)
assertCount(short, 0)
t.Run("year with thirteen day buckets", func(t *testing.T) {
longFrom := base.AddDate(-2, 0, 0).Add(17 * time.Minute)
longTo := longFrom.Add(365 * 24 * time.Hour)
width := (13 * 24 * time.Hour).Milliseconds()
var stats []models.UsageStat
wantBuckets := make(map[int64]int64)
for day := range 365 {
at := longFrom.Truncate(time.Hour).Add(time.Hour + time.Duration(day)*24*time.Hour)
row := usageStat(at, groups[0].ID, "retained", 2)
row.AccessKeyID, row.CredentialID, row.ChannelID = keys[0].ID, credentialID, "openai"
stats = append(stats, row)
wantBuckets[at.UnixMilli()-at.UnixMilli()%width] += 2
}
for _, at := range []time.Time{longFrom.Truncate(time.Hour), longTo.Truncate(time.Hour)} {
row := usageStat(at, groups[0].ID, "boundary-without-logs", 100)
row.AccessKeyID, row.CredentialID, row.ChannelID = keys[0].ID, credentialID, "openai"
stats = append(stats, row)
}
if err := db.CreateInBatches(stats, 100).Error; err != nil {
t.Fatal(err)
}
started := time.Now()
report, err := service.QueryUsage(t.Context(), UsageQuery{FromMS: longFrom.UnixMilli(), ToMS: longTo.UnixMilli(), CredentialID: &credentialID})
if err != nil {
t.Fatal(err)
}
t.Logf("year query over %d synthetic hourly rows: %s", len(stats), time.Since(started))
if report.Summary.RequestCount != 730 || len(report.Series) != len(wantBuckets) {
t.Fatalf("year report = %+v, want 730 requests in %d buckets", report, len(wantBuckets))
}
for _, point := range report.Series {
bucket := point.BucketStartMS - point.BucketStartMS%width
if point.RequestCount != wantBuckets[bucket] || point.BucketStartMS != max(bucket, longFrom.UnixMilli()) ||
point.BucketEndMS != min(bucket+width, longTo.UnixMilli()) {
t.Fatalf("year bucket = %+v, want exact clipped thirteen-day bucket", point)
}
}
assertMinuteUsageReportTotals(t, report)
})
}
+21 -65
View File
@@ -16,7 +16,6 @@ import (
const (
usageDistributionLimit = 5
maxUsageSeriesHours = 30 * 24
usageRollbackTimeout = time.Second
)
@@ -38,30 +37,20 @@ func (service *Service) QueryUsage(ctx context.Context, input UsageQuery) (Usage
CleanupTimeout: usageRollbackTimeout,
Operation: "usage read transaction",
}, func(connection *gorm.DB) error {
scope := usageStatScope
alignmentMS := epochms.MillisecondsPerHour
if input.Granularity == UsageGranularityMinute {
scope = usageRequestLogScope
alignmentMS = UsageFiveMinuteBucketMS
}
if err := validateUsageIntegrity(scope(connection, input), alignmentMS); err != nil {
scope := usageWindowScope(connection, input)
if err := validateUsageIntegrity(scope, 0); err != nil {
return err
}
summary, err := queryUsageSummary(scope(connection, input))
summary, err := queryUsageSummary(scope.Session(&gorm.Session{}))
if err != nil {
return err
}
var series []UsageSeriesPoint
if input.Granularity == UsageGranularityMinute {
series, err = queryUsageMinuteSeries(scope(connection, input), input)
} else {
series, err = queryUsageSeries(scope(connection, input), bucketWidthMS)
}
series, err := queryUsageWindowSeries(scope.Session(&gorm.Session{}), input, bucketWidthMS)
if err != nil {
return err
}
distributions, err := queryUsageDistributions(
scope(connection, input), summary, input.SelfScoped,
scope.Session(&gorm.Session{}), summary, input.SelfScoped,
)
if err != nil {
return err
@@ -78,12 +67,9 @@ func (service *Service) QueryUsage(ctx context.Context, input UsageQuery) (Usage
}
func validateUsageQuery(input UsageQuery) (int64, error) {
if input.FromMS < 0 || input.ToMS <= input.FromMS {
return 0, fmt.Errorf("query usage: invalid time range")
}
if input.ToMS-input.FromMS >
int64(maxUsageSeriesHours)*epochms.MillisecondsPerHour {
return 0, fmt.Errorf("query usage: time range exceeds %d hours", maxUsageSeriesHours)
_, bucketWidthMS, err := ResolveUsageTimeBucket(input.FromMS, input.ToMS)
if err != nil {
return 0, err
}
if input.AccessKeyID != nil && *input.AccessKeyID == 0 {
return 0, fmt.Errorf("query usage: invalid access key scope")
@@ -94,51 +80,13 @@ func validateUsageQuery(input UsageQuery) (int64, error) {
if input.CredentialID != nil && *input.CredentialID == 0 {
return 0, fmt.Errorf("query usage: invalid credential scope")
}
bucketWidthMS := input.BucketWidthMS
switch input.Granularity {
case UsageGranularityMinute:
if input.ToMS-input.FromMS != epochms.MillisecondsPerHour {
return 0, fmt.Errorf("query usage: minute granularity requires a one-hour range")
}
if bucketWidthMS != 0 && bucketWidthMS != UsageFiveMinuteBucketMS {
return 0, fmt.Errorf("query usage: minute granularity requires a five-minute bucket")
}
return UsageFiveMinuteBucketMS, nil
case UsageGranularityHour:
if bucketWidthMS == 0 {
bucketWidthMS = epochms.MillisecondsPerHour
}
case UsageGranularityDay:
if bucketWidthMS == 0 {
bucketWidthMS = epochms.MillisecondsPerDay
}
default:
return 0, fmt.Errorf("query usage: unsupported granularity %q", input.Granularity)
}
if bucketWidthMS < epochms.MillisecondsPerHour ||
bucketWidthMS > epochms.MillisecondsPerDay ||
bucketWidthMS%epochms.MillisecondsPerHour != 0 {
return 0, fmt.Errorf("query usage: invalid bucket width %d", bucketWidthMS)
}
if input.Granularity == UsageGranularityHour &&
bucketWidthMS >= epochms.MillisecondsPerDay {
return 0, fmt.Errorf("query usage: hourly granularity requires a sub-day bucket")
}
if input.Granularity == UsageGranularityDay &&
bucketWidthMS != epochms.MillisecondsPerDay {
return 0, fmt.Errorf("query usage: daily granularity requires a day bucket")
}
if input.FromMS%bucketWidthMS != 0 || input.ToMS%bucketWidthMS != 0 ||
(input.ToMS-input.FromMS)%bucketWidthMS != 0 {
return 0, fmt.Errorf("query usage: time range is not bucket aligned")
}
return bucketWidthMS, nil
}
func queryUsageDistributions(
scope *gorm.DB,
summary UsageAggregate,
accessKeyScoped bool,
selfScoped bool,
) (UsageDistributions, error) {
result := UsageDistributions{
Group: make(map[UsageDistributionMetric]UsageDistribution, 3),
@@ -150,7 +98,7 @@ func queryUsageDistributions(
UsageDistributionDimensionModel,
UsageDistributionDimensionAccessKey,
}
if accessKeyScoped {
if selfScoped {
dimensions = []UsageDistributionDimension{UsageDistributionDimensionModel}
}
for _, dimension := range dimensions {
@@ -207,10 +155,18 @@ func validateUsageStatIntegrity(scope *gorm.DB) error {
func validateUsageIntegrity(scope *gorm.DB, bucketAlignmentMS int64) error {
var integrity usageStatIntegrity
if err := scope.Select(`
alignmentExpression := "?"
var arguments []any
if bucketAlignmentMS == 0 {
// 混合行集携带原始来源的桶宽,不能把损坏的小时记录按分钟桶放行。
alignmentExpression = "bucket_alignment_ms"
} else {
arguments = append(arguments, bucketAlignmentMS)
}
if err := scope.Session(&gorm.Session{}).Select(`
COALESCE(MAX(CASE
WHEN bucket_start_ms < 0
OR bucket_start_ms % ? != 0
OR bucket_start_ms % `+alignmentExpression+` != 0
THEN 1 ELSE 0 END), 0) AS invalid_bucket,
COALESCE(MAX(CASE
WHEN request_count < 0 OR success_count < 0 OR failure_count < 0
@@ -230,7 +186,7 @@ func validateUsageIntegrity(scope *gorm.DB, bucketAlignmentMS int64) error {
COALESCE(MAX(CASE
WHEN estimated_cost_nano_usd < 0
THEN 1 ELSE 0 END), 0) AS invalid_cost
`, bucketAlignmentMS).Find(&integrity).Error; err != nil {
`, arguments...).Find(&integrity).Error; err != nil {
return fmt.Errorf("check usage stat integrity: %w", err)
}
if integrity.InvalidBucket != 0 || integrity.InvalidCount != 0 ||
+3 -29
View File
@@ -1,8 +1,6 @@
package requestlog
import (
"fmt"
"gorm.io/gorm"
"gpt-load/internal/storage/models"
@@ -29,32 +27,7 @@ func usageRequestLogScope(db *gorm.DB, input UsageQuery) *gorm.DB {
logs = logs.Where("upstream_model = ?", input.UpstreamModel)
}
return db.Session(&gorm.Session{NewDB: true}).
Table("(?) AS usage_rows", logs.Select(usageRequestLogProjection, UsageFiveMinuteBucketMS))
}
func queryUsageMinuteSeries(scope *gorm.DB, input UsageQuery) ([]UsageSeriesPoint, error) {
var source []usageHourPoint
if err := scope.Select("bucket_start_ms, " + usageAggregateSelect).
Group("bucket_start_ms").Order("bucket_start_ms ASC").Find(&source).Error; err != nil {
return nil, fmt.Errorf("query minute usage series: %w", err)
}
series := make([]UsageSeriesPoint, 0, len(source))
for _, point := range source {
if err := validateUsageAggregate(point.UsageAggregate); err != nil {
return nil, fmt.Errorf("validate minute usage series: %w", err)
}
// 首尾自然桶截取到查询范围;先比较差值,避免接近 int64 上界时相加溢出。
endMS := input.ToMS
if input.ToMS-point.BucketStartMS > UsageFiveMinuteBucketMS {
endMS = point.BucketStartMS + UsageFiveMinuteBucketMS
}
series = append(series, UsageSeriesPoint{
BucketStartMS: max(point.BucketStartMS, input.FromMS),
BucketEndMS: endMS,
UsageAggregate: point.UsageAggregate,
})
}
return series, nil
Table("(?) AS usage_rows", logs.Select(usageRequestLogProjection, UsageFiveMinuteBucketMS, UsageFiveMinuteBucketMS))
}
// 与 usageStatDelta.addRow 保持一致:只统计已完成的最终归属,每个请求仅计一次;
@@ -75,5 +48,6 @@ const usageRequestLogProjection = `
CASE WHEN usage_state = 'missing' THEN 1 ELSE 0 END AS usage_missing_count,
CASE WHEN usage_state = 'partial' THEN 1 ELSE 0 END AS partial_count,
CASE WHEN usage_state IN ('complete', 'partial') AND cost_state = 'unpriced' THEN 1 ELSE 0 END AS unpriced_request_count,
CASE WHEN cost_state = 'priced' AND pricing_completeness = 'partial' THEN 1 ELSE 0 END AS pricing_partial_count
CASE WHEN cost_state = 'priced' AND pricing_completeness = 'partial' THEN 1 ELSE 0 END AS pricing_partial_count,
? + 0 AS bucket_alignment_ms
`
+12 -9
View File
@@ -86,13 +86,14 @@ func TestQueryUsageMinuteCanIncludeThirteenPartialAndFullBuckets(t *testing.T) {
func TestQueryUsageMinuteClipsBucketNearTimestampLimit(t *testing.T) {
db := openRequestLogQueryDB(t)
start := time.UnixMilli(math.MaxInt64 - time.Hour.Milliseconds())
row := aggregationRow(aggregationRequestID(700), time.UnixMilli(math.MaxInt64-1), 7, "model")
const maxSafeMilliseconds int64 = 1<<53 - 1
start := time.UnixMilli(maxSafeMilliseconds - time.Hour.Milliseconds())
row := aggregationRow(aggregationRequestID(700), time.UnixMilli(maxSafeMilliseconds-1), 7, "model")
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
report, err := newRequestLogTestService(db).QueryUsage(context.Background(), minuteUsageQuery(start))
if err != nil || len(report.Series) != 1 || report.Series[0].BucketEndMS != math.MaxInt64 ||
if err != nil || len(report.Series) != 1 || report.Series[0].BucketEndMS != maxSafeMilliseconds ||
report.Series[0].BucketStartMS < start.UnixMilli() {
t.Fatalf("QueryUsage() series/error = %#v/%v, want clipped bucket without overflow", report.Series, err)
}
@@ -132,6 +133,7 @@ func TestQueryUsageMinuteMatchesFrozenHourlyAccounting(t *testing.T) {
}
hourQuery := minuteUsageQuery(start)
hourQuery.Granularity = UsageGranularityHour
hourQuery.ToMS = start.Add(2 * time.Hour).UnixMilli()
hour, err := service.QueryUsage(context.Background(), hourQuery)
if err != nil {
t.Fatalf("hour QueryUsage() error = %v", err)
@@ -231,22 +233,23 @@ func TestQueryUsageMinuteUsesOneReadSnapshot(t *testing.T) {
assertMinuteUsageReportTotals(t, report)
}
func TestQueryUsageMinuteRejectsInvalidRanges(t *testing.T) {
func TestQueryUsageDerivesWidthFromTimeRange(t *testing.T) {
start := time.Date(2026, time.September, 7, 13, 2, 0, 0, time.UTC)
for _, test := range []struct {
name string
duration time.Duration
width time.Duration
want time.Duration
}{
{"short range", 59 * time.Minute, 5 * time.Minute},
{"long range", 61 * time.Minute, 5 * time.Minute},
{"wrong bucket", time.Hour, time.Minute},
{"short range", 59 * time.Minute, 5 * time.Minute, 5 * time.Minute},
{"long range", 61 * time.Minute, 5 * time.Minute, time.Hour},
{"caller bucket is ignored", time.Hour, time.Minute, 5 * time.Minute},
} {
t.Run(test.name, func(t *testing.T) {
query := minuteUsageQuery(start)
query.ToMS, query.BucketWidthMS = start.Add(test.duration).UnixMilli(), test.width.Milliseconds()
if _, err := validateUsageQuery(query); err == nil {
t.Fatal("validateUsageQuery() error = nil, want invalid minute range rejection")
if width, err := validateUsageQuery(query); err != nil || width != test.want.Milliseconds() {
t.Fatalf("validateUsageQuery() width/error = %d/%v, want %s", width, err, test.want)
}
})
}
+27 -20
View File
@@ -93,7 +93,7 @@ func TestQueryUsageExcludesLegacyZeroAttemptAggregates(t *testing.T) {
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -132,7 +132,7 @@ func TestQueryUsageScopesAccessKeyAndDistributesByModel(t *testing.T) {
accessKeyID := uint(41)
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
AccessKeyID: &accessKeyID,
SelfScoped: true,
@@ -164,7 +164,7 @@ func TestQueryUsageRejectsZeroAccessKeyScope(t *testing.T) {
_, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
AccessKeyID: &zero,
})
@@ -193,7 +193,7 @@ func TestQueryUsageDistributionAggregatesCredentialsAndFoldsRemainder(t *testing
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -246,7 +246,7 @@ func TestQueryUsageGroupDistributionKeepsOnlyPersistedGroupsInTopFive(t *testing
} {
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -312,7 +312,7 @@ func TestQueryUsageAccessKeyTokenDistributionKeepsOnlyPersistedKeys(t *testing.T
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -356,7 +356,7 @@ func TestQueryUsageModelDistributionAggregatesGroupsAndCredentials(t *testing.T)
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -409,8 +409,15 @@ func TestQueryUsageMergesHourlyRowsIntoThirtyUTCDays(t *testing.T) {
func TestQueryUsageMergesHourlyRowsIntoAdaptiveBuckets(t *testing.T) {
start := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
for _, width := range []time.Duration{3 * time.Hour, 6 * time.Hour, 12 * time.Hour} {
t.Run(width.String(), func(t *testing.T) {
for _, test := range []struct {
width, span time.Duration
}{
{3 * time.Hour, 3 * 24 * time.Hour},
{6 * time.Hour, 7 * 24 * time.Hour},
{12 * time.Hour, 15 * 24 * time.Hour},
} {
t.Run(test.width.String(), func(t *testing.T) {
width := test.width
db := openRequestLogQueryDB(t)
service := newRequestLogTestService(db)
createUsageStats(
@@ -424,7 +431,7 @@ func TestQueryUsageMergesHourlyRowsIntoAdaptiveBuckets(t *testing.T) {
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(2 * width).UnixMilli(),
ToMS: start.Add(test.span).UnixMilli(),
Granularity: UsageGranularityHour,
BucketWidthMS: width.Milliseconds(),
})
@@ -451,7 +458,7 @@ func TestQueryUsageMergesHourlyRowsIntoAdaptiveBuckets(t *testing.T) {
}
}
func TestQueryUsageRejectsInvalidBucketWidths(t *testing.T) {
func TestQueryUsageIgnoresCallerBucketWidths(t *testing.T) {
start := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
@@ -476,8 +483,8 @@ func TestQueryUsageRejectsInvalidBucketWidths(t *testing.T) {
Granularity: test.granularity,
BucketWidthMS: test.width.Milliseconds(),
})
if err == nil {
t.Fatal("QueryUsage() error = nil, want invalid bucket width rejection")
if err != nil {
t.Fatalf("QueryUsage() error = %v, want bucket derived from time range", err)
}
})
}
@@ -515,7 +522,7 @@ func TestQueryUsageOrdersDistributionByRequestsAndCost(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -555,7 +562,7 @@ func TestQueryUsageUsesOneReadSnapshot(t *testing.T) {
inserted := false
const callbackName = "test:usage_query_snapshot_insert"
if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if inserted || tx.Statement.Table != "usage_stats" {
if inserted || tx.DryRun {
return
}
inserted = true
@@ -579,7 +586,7 @@ func TestQueryUsageUsesOneReadSnapshot(t *testing.T) {
report, err := service.QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err != nil {
@@ -603,7 +610,7 @@ func TestQueryUsageCancelsAfterBeginWithoutPoisoningDatabaseConnection(t *testin
cancelled := false
const callbackName = "test:usage_query_cancel_after_begin"
if err := db.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if cancelled || tx.Statement.Table != "usage_stats" {
if cancelled || tx.DryRun {
return
}
cancelled = true
@@ -619,7 +626,7 @@ func TestQueryUsageCancelsAfterBeginWithoutPoisoningDatabaseConnection(t *testin
_, err := service.QueryUsage(ctx, UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err == nil || !cancelled {
@@ -675,7 +682,7 @@ func TestQueryUsageRejectsCorruptRowsOutsideTopDistribution(t *testing.T) {
input := UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
}
summary, err := queryUsageSummary(usageStatScope(db, input))
@@ -801,7 +808,7 @@ func TestQueryUsageRejectsCorruptAggregates(t *testing.T) {
createCorruptUsageStats(t, db, test.rows...)
_, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: start.UnixMilli(),
ToMS: start.Add(time.Hour).UnixMilli(),
ToMS: start.Add(2 * time.Hour).UnixMilli(),
Granularity: UsageGranularityHour,
})
if err == nil {
+97
View File
@@ -0,0 +1,97 @@
package requestlog
import (
"fmt"
"gorm.io/gorm"
"gpt-load/internal/platform/epochms"
)
// ResolveUsageTimeBucket 根据精确时间跨度选择趋势桶,不依赖前端快捷范围。
func ResolveUsageTimeBucket(fromMS, toMS int64) (UsageGranularity, int64, error) {
const maxSafeMilliseconds int64 = 1<<53 - 1
if fromMS < 0 || toMS <= fromMS || toMS > maxSafeMilliseconds {
return "", 0, fmt.Errorf("query usage: invalid or unsafe time range")
}
spanMS := toMS - fromMS
hour, day := epochms.MillisecondsPerHour, epochms.MillisecondsPerDay
switch {
case spanMS <= hour:
return UsageGranularityMinute, UsageFiveMinuteBucketMS, nil
case spanMS <= day:
return UsageGranularityHour, hour, nil
case spanMS <= 3*day:
return UsageGranularityHour, 3 * hour, nil
case spanMS <= 7*day:
return UsageGranularityHour, 6 * hour, nil
case spanMS <= 15*day:
return UsageGranularityHour, 12 * hour, nil
default:
// 向上取整时不先加除数,超长区间仍保持安全整数运算。
return UsageGranularityDay, ((spanMS-1)/(30*day) + 1) * day, nil
}
}
// usageWindowScope 先按时间拆分来源,再合并行集供总览、趋势和分布使用。
// 数据缺失不会改变分段,也不会把首尾小时的整桶统计带入精确区间。
func usageWindowScope(db *gorm.DB, input UsageQuery) *gorm.DB {
hour := epochms.MillisecondsPerHour
if input.ToMS-input.FromMS <= hour {
return usageRequestLogScope(db, input)
}
fullFromMS := input.FromMS
if remainder := fullFromMS % hour; remainder != 0 {
fullFromMS += hour - remainder
}
fullToMS := input.ToMS - input.ToMS%hour
full := input
full.FromMS, full.ToMS = fullFromMS, fullToMS
// 数值运算固定参数类型,避免 PostgreSQL 把 SELECT 中的独立参数推断为 text。
parts := []any{usageStatScope(db, full).Select(usageWindowColumns+", ? + 0 AS bucket_alignment_ms", hour)}
unionSQL := "?"
for _, boundary := range [][2]int64{{input.FromMS, fullFromMS}, {fullToMS, input.ToMS}} {
if boundary[0] >= boundary[1] {
continue
}
part := input
part.FromMS, part.ToMS = boundary[0], boundary[1]
parts = append(parts, usageRequestLogScope(db, part).Select(usageWindowColumns+", bucket_alignment_ms"))
unionSQL += " UNION ALL ?"
}
return db.Session(&gorm.Session{NewDB: true}).Table("("+unionSQL+") AS usage_rows", parts...)
}
func queryUsageWindowSeries(scope *gorm.DB, input UsageQuery, widthMS int64) ([]UsageSeriesPoint, error) {
var rows []struct {
BucketStartMS int64 `gorm:"column:series_bucket_ms"`
UsageAggregate
}
// 直接按最终自然桶聚合,年度等长区间不会把每个小时传回进程再合并。
if err := scope.Select("bucket_start_ms - bucket_start_ms % ? AS series_bucket_ms, "+usageAggregateSelect, widthMS).
Group("series_bucket_ms").Order("series_bucket_ms ASC").
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("query usage window series: %w", err)
}
series := make([]UsageSeriesPoint, 0, len(rows))
for _, row := range rows {
if err := validateUsageAggregate(row.UsageAggregate); err != nil {
return nil, fmt.Errorf("validate usage window series: %w", err)
}
endMS := input.ToMS
if input.ToMS-row.BucketStartMS > widthMS {
endMS = row.BucketStartMS + widthMS
}
series = append(series, UsageSeriesPoint{
BucketStartMS: max(row.BucketStartMS, input.FromMS), BucketEndMS: endMS,
UsageAggregate: row.UsageAggregate,
})
}
return series, nil
}
const usageWindowColumns = `bucket_start_ms, group_id, access_key_id, model,
request_count, success_count, failure_count,
uncached_input_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens,
cache_write_unknown_tokens, output_tokens, estimated_cost_nano_usd,
usage_missing_count, partial_count, unpriced_request_count, pricing_partial_count`
@@ -0,0 +1,332 @@
package requestlog
import (
"context"
"fmt"
"math"
"testing"
"time"
"gpt-load/internal/channel"
"gpt-load/internal/platform/epochms"
"gpt-load/internal/storage/models"
)
func TestQueryUsageExactWindowBoundaries(t *testing.T) {
base := time.Date(2026, time.September, 8, 13, 0, 0, 0, time.UTC)
for _, test := range []struct {
name string
from, to time.Duration
width time.Duration
}{
{"within hour", 17 * time.Minute, 38 * time.Minute, 5 * time.Minute},
{"short across hour", 57 * time.Minute, 63 * time.Minute, 5 * time.Minute},
{"exact aligned hour", 0, time.Hour, 5 * time.Minute},
{"exact rolling hour", 17 * time.Minute, 77 * time.Minute, 5 * time.Minute},
{"aligned hour plus millisecond", 0, time.Hour + time.Millisecond, time.Hour},
{"rolling hour plus millisecond", 17 * time.Minute, 77*time.Minute + time.Millisecond, time.Hour},
{"full hours and boundaries", 17 * time.Minute, 190 * time.Minute, time.Hour},
{"aligned full hours", 0, 3 * time.Hour, time.Hour},
} {
t.Run(test.name, func(t *testing.T) {
db := openRequestLogQueryDB(t)
from, to := base.Add(test.from), base.Add(test.to)
rows := []models.RequestLog{
aggregationRow(aggregationRequestID(800), from.Add(-time.Millisecond), 7, "model"),
aggregationRow(aggregationRequestID(801), from, 7, "model"),
aggregationRow(aggregationRequestID(802), from.Add(to.Sub(from)/2), 7, "model"),
aggregationRow(aggregationRequestID(803), to.Add(-time.Millisecond), 7, "model"),
aggregationRow(aggregationRequestID(804), to, 7, "model"),
}
if err := (&gormBatchWriter{db: db}).WriteBatch(context.Background(), rows); err != nil {
t.Fatal(err)
}
report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: from.UnixMilli(), ToMS: to.UnixMilli(), Granularity: UsageGranularityHour,
})
if err != nil {
t.Fatalf("QueryUsage() error = %v", err)
}
if report.Summary.RequestCount != 3 {
t.Fatalf("request count = %d, want exactly three in-range requests", report.Summary.RequestCount)
}
for _, point := range report.Series {
bucketStart := point.BucketStartMS - point.BucketStartMS%test.width.Milliseconds()
if point.BucketStartMS != max(bucketStart, from.UnixMilli()) ||
point.BucketEndMS != min(bucketStart+test.width.Milliseconds(), to.UnixMilli()) {
t.Fatalf("bucket = %#v, want clipped %s natural bucket", point, test.width)
}
}
assertMinuteUsageReportTotals(t, report)
})
}
}
func TestResolveUsageTimeBucket(t *testing.T) {
hour, day := epochms.MillisecondsPerHour, epochms.MillisecondsPerDay
for _, test := range []struct {
span int64
granularity UsageGranularity
width int64
}{
{1, UsageGranularityMinute, UsageFiveMinuteBucketMS},
{hour, UsageGranularityMinute, UsageFiveMinuteBucketMS},
{hour + 1, UsageGranularityHour, hour},
{day, UsageGranularityHour, hour},
{day + 1, UsageGranularityHour, 3 * hour},
{3 * day, UsageGranularityHour, 3 * hour},
{3*day + 1, UsageGranularityHour, 6 * hour},
{7 * day, UsageGranularityHour, 6 * hour},
{7*day + 1, UsageGranularityHour, 12 * hour},
{15 * day, UsageGranularityHour, 12 * hour},
{15*day + 1, UsageGranularityDay, day},
{30 * day, UsageGranularityDay, day},
{30*day + 1, UsageGranularityDay, 2 * day},
{60 * day, UsageGranularityDay, 2 * day},
{60*day + 1, UsageGranularityDay, 3 * day},
{365 * day, UsageGranularityDay, 13 * day},
} {
t.Run(fmt.Sprint(test.span), func(t *testing.T) {
granularity, width, err := ResolveUsageTimeBucket(123, 123+test.span)
if err != nil || granularity != test.granularity || width != test.width {
t.Fatalf("bucket = %s/%d/%v, want %s/%d", granularity, width, err, test.granularity, test.width)
}
})
}
const maximum int64 = 1<<53 - 1
for _, bounds := range [][2]int64{{-1, hour}, {0, 0}, {1, 0}, {maximum, maximum + 1}, {0, math.MaxInt64}} {
if _, _, err := ResolveUsageTimeBucket(bounds[0], bounds[1]); err == nil {
t.Fatalf("bounds %v accepted, want invalid or unsafe interval rejected", bounds)
}
}
if granularity, width, err := ResolveUsageTimeBucket(0, maximum); err != nil ||
granularity != UsageGranularityDay || width <= 0 || (maximum-1)/width+1 > 30 {
t.Fatalf("maximum safe span = %s/%d/%v", granularity, width, err)
}
}
func TestQueryUsageMergesSourcesBeforeRankingEveryDimension(t *testing.T) {
db := openRequestLogQueryDB(t)
start := time.Date(2026, time.September, 8, 14, 0, 0, 0, time.UTC)
for groupID := uint(1); groupID <= 11; groupID++ {
count := int64(6)
if groupID > 5 {
count = 0
}
if groupID == 11 {
count = 5
}
stat := usageStat(start, groupID, fmt.Sprintf("model-%02d", groupID), count)
stat.AccessKeyID = groupID
stat.UncachedInputTokens, stat.OutputTokens = count, 2*count
stat.CacheReadTokens, stat.CacheWrite5MTokens, stat.CacheWrite1HTokens = 0, 0, 0
stat.EstimatedCostNanoUSD = count * 250_000_000
createUsageStats(t, db, stat)
if err := db.Create(&models.AccessKey{
ID: groupID, Name: fmt.Sprintf("key-%d", groupID), KeyValue: fmt.Sprintf("cipher-%d", groupID),
KeyHash: fmt.Sprintf("hash-%d", groupID), KeySuffix: "0000", Status: "active", Filters: models.JSON(`{}`),
}).Error; err != nil {
t.Fatal(err)
}
if groupID <= 5 {
continue
}
logCount := 6
if groupID == 11 {
logCount = 5
}
for index := 0; index < logCount; index++ {
row := aggregationRow(fmt.Sprintf("rank-%d-%d", groupID, index), start.Add(-time.Minute), groupID, stat.Model)
row.AccessKeyID = groupID
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
}
report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(),
})
if err != nil {
t.Fatal(err)
}
if report.Summary.RequestCount != 70 {
t.Fatalf("summary count = %d, want 70", report.Summary.RequestCount)
}
for _, distributions := range []map[UsageDistributionMetric]UsageDistribution{report.Distributions.Group, report.Distributions.Model, report.Distributions.AccessKey} {
for _, distribution := range distributions {
if len(distribution.Items) != 5 || distribution.Items[0].RequestCount != 10 {
t.Fatalf("%s/%s = %#v, want the source-local sixth item ranked first after merging", distribution.Dimension, distribution.Metric, distribution)
}
top := distribution.Items[0]
if top.GroupID != 11 && top.AccessKeyID != 11 && top.Model != "model-11" {
t.Fatalf("unexpected top item: %#v", top)
}
}
}
assertMinuteUsageReportTotals(t, report)
}
func TestQueryUsageMixedSourcesApplyAllFilters(t *testing.T) {
db := openRequestLogQueryDB(t)
start := time.Date(2026, time.September, 8, 14, 0, 0, 0, time.UTC)
for index := 0; index < 6; index++ {
stat := usageStat(start, 7, "model", 1)
stat.AccessKeyID, stat.CredentialID, stat.ChannelID = 41, 11, "openai"
switch index {
case 1:
stat.GroupID = 8
case 2:
stat.AccessKeyID = 42
case 3:
stat.CredentialID = 12
case 4:
stat.ChannelID = "anthropic"
case 5:
stat.Model = "other"
}
createUsageStats(t, db, stat)
for _, offset := range []time.Duration{-time.Minute, 61 * time.Minute} {
row := aggregationRow(fmt.Sprintf("filter-%d-%d", index, offset), start.Add(offset), stat.GroupID, stat.Model)
row.AccessKeyID, row.CredentialID, row.ChannelID = stat.AccessKeyID, stat.CredentialID, stat.ChannelID
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
}
groupID, credentialID, accessKeyID := uint(7), uint(11), uint(41)
report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(),
GroupID: &groupID, CredentialID: &credentialID, AccessKeyID: &accessKeyID, ChannelID: channel.OpenAI, UpstreamModel: "model",
})
if err != nil || report.Summary.RequestCount != 3 {
t.Fatalf("filtered count/error = %d/%v, want three requests across all sources", report.Summary.RequestCount, err)
}
assertMinuteUsageReportTotals(t, report)
}
func TestQueryUsageExactAccessKeyFilterKeepsAdministratorDistributions(t *testing.T) {
for _, span := range []time.Duration{30 * time.Minute, 2*time.Hour + 2*time.Minute} {
for _, persisted := range []bool{true, false} {
for _, selfScoped := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/persisted=%t/self=%t", span, persisted, selfScoped), func(t *testing.T) {
db := openRequestLogQueryDB(t)
from := time.Date(2026, time.September, 8, 13, 17, 0, 0, time.UTC)
to := from.Add(span)
keyID := uint(41)
if persisted {
if err := db.Create(&models.AccessKey{
ID: keyID, Name: "filtered", KeyValue: "cipher-filtered", KeyHash: "hash-filtered",
KeySuffix: "0041", Status: "active", Filters: models.JSON(`{}`),
}).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&models.Group{
ID: 7, Name: "filtered-group", ChannelID: "openai", Params: models.JSON(`{}`),
Models: models.JSON(`[]`), Enabled: true,
}).Error; err != nil {
t.Fatal(err)
}
var rows []models.RequestLog
for index, at := range []time.Time{from.Add(time.Minute), from.Add(span / 2), to.Add(-time.Minute)} {
row := aggregationRow(fmt.Sprintf("filtered-%d", index), at, 7, "filtered-model")
row.AccessKeyID = keyID
rows = append(rows, row)
other := row
other.ID, other.AccessKeyID = fmt.Sprintf("other-%d", index), 42
rows = append(rows, other)
}
if err := (&gormBatchWriter{db: db}).WriteBatch(t.Context(), rows); err != nil {
t.Fatal(err)
}
report, err := newRequestLogTestService(db).QueryUsage(t.Context(), UsageQuery{
FromMS: from.UnixMilli(), ToMS: to.UnixMilli(), AccessKeyID: &keyID, SelfScoped: selfScoped,
})
if err != nil || report.Summary.RequestCount != 3 {
t.Fatalf("filtered request count/error = %d/%v, want three requests", report.Summary.RequestCount, err)
}
if selfScoped {
if len(report.Distributions.Group) != 0 || len(report.Distributions.AccessKey) != 0 {
t.Fatalf("self-scoped view exposes management dimensions: %#v", report.Distributions)
}
} else {
group := usageDistribution(t, report, UsageDistributionDimensionGroup, UsageDistributionMetricRequests)
if len(group.Items) != 1 || group.Items[0].GroupID != 7 || group.Items[0].RequestCount != 3 {
t.Fatalf("administrator key filter lost group distribution: %#v", group)
}
key := usageDistribution(t, report, UsageDistributionDimensionAccessKey, UsageDistributionMetricRequests)
if persisted {
if len(key.Items) != 1 || key.Items[0].AccessKeyID != keyID || key.Items[0].RequestCount != 3 {
t.Fatalf("administrator key filter lost access-key distribution: %#v", key)
}
} else if len(key.Items) != 0 || key.Other == nil || key.Other.RequestCount != 3 {
t.Fatalf("historical key must remain in Other: %#v", key)
}
}
assertMinuteUsageReportTotals(t, report)
})
}
}
}
}
func TestQueryUsageMixedSourcesRejectCorruptRowsBeforeCombining(t *testing.T) {
for _, kind := range []string{"misaligned hour", "negative hourly count", "negative log tokens"} {
t.Run(kind, func(t *testing.T) {
db := openRequestLogQueryDB(t)
start := time.Date(2026, time.September, 8, 14, 0, 0, 0, time.UTC)
if err := db.Exec(`PRAGMA ignore_check_constraints = ON`).Error; err != nil {
t.Fatal(err)
}
first, second := usageStat(start, 7, "model", 100), usageStat(start, 8, "model", 1)
switch kind {
case "misaligned hour":
second.BucketStartMS += UsageFiveMinuteBucketMS
case "negative hourly count":
second.RequestCount, second.SuccessCount = -1, -1
}
createUsageStats(t, db, first, second)
row := aggregationRow("corrupt-boundary", start.Add(-time.Minute), 7, "model")
if kind == "negative log tokens" {
row.UncachedInputTokens = -1
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
if _, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: start.Add(-30 * time.Minute).UnixMilli(), ToMS: start.Add(90 * time.Minute).UnixMilli(),
}); err == nil {
t.Fatal("corrupt source accepted after other rows compensated its totals")
}
})
}
}
func TestQueryUsageLongWindowAndMissingBoundaryLogs(t *testing.T) {
db := openRequestLogQueryDB(t)
from := time.Date(2025, time.September, 8, 13, 17, 0, 0, time.UTC)
to := from.Add(365 * 24 * time.Hour)
createUsageStats(t, db,
usageStat(from.Truncate(time.Hour), 7, "boundary-without-logs", 100),
usageStat(from.Truncate(time.Hour).Add(time.Hour), 7, "retained", 2),
usageStat(to.Truncate(time.Hour).Add(-time.Hour), 7, "retained", 3),
usageStat(to.Truncate(time.Hour), 7, "boundary-without-logs", 200),
)
report, err := newRequestLogTestService(db).QueryUsage(context.Background(), UsageQuery{
FromMS: from.UnixMilli(), ToMS: to.UnixMilli(), Granularity: UsageGranularityDay,
})
if err != nil {
t.Fatalf("QueryUsage() error = %v", err)
}
if report.Summary.RequestCount != 5 || len(report.Series) != 2 {
t.Fatalf("report = %#v, want two retained buckets and five requests", report)
}
width := (13 * 24 * time.Hour).Milliseconds()
for _, point := range report.Series {
start := point.BucketStartMS - point.BucketStartMS%width
if point.BucketStartMS != max(start, from.UnixMilli()) || point.BucketEndMS != min(start+width, to.UnixMilli()) {
t.Fatalf("long-window bucket = %#v", point)
}
}
assertMinuteUsageReportTotals(t, report)
}
+2 -2
View File
@@ -54,9 +54,9 @@ export function normalizeAccessKeyCollectionFilters(
function normalizeUsageFilters(filters: UsageFilters): UsageFilters {
const result: UsageFilters = {
range: filters.range,
from_ms: filters.from_ms,
to_ms: filters.to_ms,
}
if (filters.at_ms !== undefined) result.at_ms = filters.at_ms
if (filters.access_key_id !== undefined) result.access_key_id = filters.access_key_id
if (filters.group_id !== undefined) result.group_id = filters.group_id
if (filters.channel_id !== undefined) result.channel_id = filters.channel_id
+28 -48
View File
@@ -4,7 +4,6 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import type { ApiClient } from '@/api/client'
import { InvalidResponseError } from '@/api/errors'
import { controlQueryKeys } from '@/app/query-keys'
import { timeRanges, type TimeRange } from '@/lib/time'
import {
assertNoSecretLikeFields,
@@ -20,12 +19,10 @@ import {
export type UsageDistributionDimension = 'group' | 'model' | 'access_key'
export type UsageDistributionMetric = 'requests' | 'tokens' | 'cost'
export const usageRanges = timeRanges
export type UsageRange = TimeRange
export interface UsageFilters {
range: UsageRange
at_ms?: number
from_ms: number
to_ms: number
access_key_id?: number
group_id?: number
channel_id?: string
@@ -58,7 +55,6 @@ export interface UsageDistributionAggregateDto {
}
export interface UsageReportDto {
range: UsageRange
granularity: 'minute' | 'hour' | 'day'
bucket_width_ms: number
from_ms: number
@@ -115,7 +111,6 @@ const distributionAggregateFields = [
'estimated_cost_nano_usd',
] as const
const reportFields = [
'range',
'granularity',
'bucket_width_ms',
'from_ms',
@@ -129,26 +124,11 @@ const reportFields = [
const hourMs = 60 * 60 * 1000
const dayMs = 24 * hourMs
const fiveMinutesMs = 5 * 60 * 1000
const usageRangeContract: Record<
UsageRange,
{ granularity: UsageReportDto['granularity']; bucketWidthMs: number; buckets: number }
> = {
'1h': { granularity: 'minute', bucketWidthMs: fiveMinutesMs, buckets: 12 },
'24h': { granularity: 'hour', bucketWidthMs: hourMs, buckets: 24 },
'3d': { granularity: 'hour', bucketWidthMs: 3 * hourMs, buckets: 24 },
'7d': { granularity: 'hour', bucketWidthMs: 6 * hourMs, buckets: 28 },
'15d': { granularity: 'hour', bucketWidthMs: 12 * hourMs, buckets: 30 },
'30d': { granularity: 'day', bucketWidthMs: dayMs, buckets: 30 },
}
function invalidResponse(): never {
throw new InvalidResponseError()
}
function isUTCAligned(timestampValue: number, bucketWidthMs: number): boolean {
return timestampValue % bucketWidthMs === 0
}
export function projectUsageAggregate(value: unknown): UsageAggregateDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, aggregateFields)
@@ -219,27 +199,19 @@ function projectCollectionHealth(value: unknown): UsageReportDto['collection_hea
export function projectUsageReport(value: unknown): UsageReportDto {
const record = projectRecord(value)
assertNoSecretLikeFields(record, reportFields)
const range = projectEnum(record.range, usageRanges)
const granularity = projectEnum(record.granularity, ['minute', 'hour', 'day'] as const)
const rangeContract = usageRangeContract[range]
if (granularity !== rangeContract.granularity) invalidResponse()
const bucketWidthMs = projectSafeInteger(record.bucket_width_ms, { minimum: fiveMinutesMs })
if (bucketWidthMs !== rangeContract.bucketWidthMs) invalidResponse()
const observedAtMS = projectEpochMilliseconds(record.observed_at_ms)
const rangeFromMS = projectEpochMilliseconds(record.from_ms)
const rangeToMS = projectEpochMilliseconds(record.to_ms)
const bucketCount = rangeContract.buckets
const rollingHour = range === '1h'
if (
rangeToMS - rangeFromMS !== bucketWidthMs * bucketCount ||
(rollingHour
? rangeToMS > observedAtMS
: !isUTCAligned(rangeFromMS, bucketWidthMs) ||
!isUTCAligned(rangeToMS, bucketWidthMs) ||
observedAtMS < rangeToMS - bucketWidthMs)
(granularity === 'minute' && bucketWidthMs !== fiveMinutesMs) ||
(granularity === 'hour' && bucketWidthMs % hourMs !== 0) ||
(granularity === 'day' && bucketWidthMs % dayMs !== 0)
) {
invalidResponse()
}
const observedAtMS = projectEpochMilliseconds(record.observed_at_ms)
const rangeFromMS = projectEpochMilliseconds(record.from_ms)
const rangeToMS = projectEpochMilliseconds(record.to_ms)
if (rangeToMS <= rangeFromMS) invalidResponse()
let previousBucketEndMS = rangeFromMS
const series = projectArray(record.series, (value) => {
@@ -254,12 +226,8 @@ export function projectUsageReport(value: unknown): UsageReportDto {
const bucketEndMS = projectEpochMilliseconds(item.bucket_end_ms)
const alignedBucketStartMS = Math.floor(bucketStartMS / bucketWidthMs) * bucketWidthMs
if (
(rollingHour
? bucketStartMS !== Math.max(alignedBucketStartMS, rangeFromMS) ||
bucketEndMS !== Math.min(alignedBucketStartMS + bucketWidthMs, rangeToMS)
: !isUTCAligned(bucketStartMS, bucketWidthMs) ||
!isUTCAligned(bucketEndMS, bucketWidthMs) ||
bucketEndMS - bucketStartMS !== bucketWidthMs) ||
bucketStartMS !== Math.max(alignedBucketStartMS, rangeFromMS) ||
bucketEndMS !== Math.min(alignedBucketStartMS + bucketWidthMs, rangeToMS) ||
bucketEndMS <= bucketStartMS ||
bucketStartMS < rangeFromMS ||
bucketEndMS > rangeToMS ||
@@ -394,9 +362,19 @@ export function projectUsageReport(value: unknown): UsageReportDto {
? projectDistributionMetricRecord(distributionsRecord.access_key, 'access_key')
: undefined
const summary = projectUsageAggregate(record.summary)
for (const field of aggregateKeys) {
if (series.reduce((total, bucket) => total + bucket[field], 0) !== summary[field]) {
invalidResponse()
}
}
if (
series.reduce((total, bucket) => total + BigInt(bucket.estimated_cost_nano_usd), 0n) !==
BigInt(summary.estimated_cost_nano_usd)
) {
invalidResponse()
}
return {
range,
granularity,
bucket_width_ms: bucketWidthMs,
from_ms: rangeFromMS,
@@ -414,8 +392,7 @@ export function projectUsageReport(value: unknown): UsageReportDto {
}
export function normalizeUsageFilters(filters: UsageFilters): UsageFilters {
const result: UsageFilters = { range: filters.range }
if (filters.at_ms !== undefined) result.at_ms = filters.at_ms
const result: UsageFilters = { from_ms: filters.from_ms, to_ms: filters.to_ms }
if (filters.access_key_id !== undefined) result.access_key_id = filters.access_key_id
if (filters.group_id !== undefined) result.group_id = filters.group_id
if (filters.channel_id !== undefined) result.channel_id = filters.channel_id
@@ -434,8 +411,10 @@ export async function getUsageReport(
signal?: AbortSignal,
): Promise<UsageReportDto> {
const normalized = normalizeUsageFilters(filters)
const params = new URLSearchParams([['range', normalized.range]])
if (normalized.at_ms !== undefined) params.append('at_ms', String(normalized.at_ms))
const params = new URLSearchParams([
['from_ms', String(normalized.from_ms)],
['to_ms', String(normalized.to_ms)],
])
if (normalized.access_key_id !== undefined)
params.append('access_key_id', String(normalized.access_key_id))
if (normalized.group_id !== undefined) params.append('group_id', String(normalized.group_id))
@@ -449,6 +428,7 @@ export async function getUsageReport(
const report = projectUsageReport(
await client.request(`/api/usage?${params.toString()}`, { method: 'GET', signal }),
)
if (report.from_ms !== normalized.from_ms || report.to_ms !== normalized.to_ms) invalidResponse()
return report
}
+8 -1
View File
@@ -29,6 +29,7 @@ const props = withDefaults(
centerBuckets?: boolean
locale?: string
showBucketRange?: boolean
showBucketSeconds?: boolean
showSinglePoint?: boolean
failureRateLabel?: string
}>(),
@@ -36,6 +37,7 @@ const props = withDefaults(
locale: 'en-US',
centerBuckets: false,
showBucketRange: false,
showBucketSeconds: false,
showSinglePoint: false,
failureRateLabel: undefined,
},
@@ -116,7 +118,12 @@ watch(seriesKey, () => {
function formatBucketTime(datum: TrendDatum): string {
return props.showBucketRange
? formatLocalTimeRange(datum.bucket_start_ms, datum.bucket_end_ms, props.locale)
? formatLocalTimeRange(
datum.bucket_start_ms,
datum.bucket_end_ms,
props.locale,
props.showBucketSeconds,
)
: formatLocalInstant(datum.bucket_start_ms, props.locale)
}
+139 -45
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { CalendarClock } from '@lucide/vue'
import { computed, ref } from 'vue'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { currentTimeZone, timeRangeMilliseconds, timeRanges } from '@/lib/time'
import {
dateTimePresets,
localDateTimeInput,
resolveDateTimePreset,
type DateTimePreset,
} from '@/lib/time'
import AppButton from './AppButton.vue'
import AppPopover from './AppPopover.vue'
@@ -13,23 +18,39 @@ import OverflowTooltip from './OverflowTooltip.vue'
const props = defineProps<{
from: string
to: string
appliedFrom?: string
appliedTo?: string
label: string
fromLabel: string
toLabel: string
timezoneLabel: string
fromError?: string
toError?: string
preset?: DateTimePreset
rollingEndOffsetMs?: number
applyLabel?: string
applyDisabled?: boolean
}>()
const emit = defineEmits<{
'update:from': [value: string]
'update:to': [value: string]
'update:preset': [value: DateTimePreset | undefined]
shortcut: [preset: DateTimePreset, from: number, to: number]
apply: []
open: []
}>()
const { t } = useI18n()
const open = ref(false)
const timezone = currentTimeZone()
const shortcuts = timeRanges.map((key) => ({ key, milliseconds: timeRangeMilliseconds[key] }))
const fieldID = useId()
const display = computed(() => `${displayValue(props.from)} → ${displayValue(props.to)}`)
const display = computed(
() =>
`${displayValue(props.appliedFrom ?? props.from)} → ${displayValue(props.appliedTo ?? props.to)}`,
)
function changeOpen(value: boolean): void {
open.value = value
if (value) emit('open')
}
function displayValue(value: string): string {
const normalized = normalizeLocalInputValue(value)
@@ -38,10 +59,11 @@ function displayValue(value: string): string {
function normalizeLocalInputValue(value: string): string {
if (!value) return ''
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/u.test(value) ? `${value}:00` : value
return /^\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}$/u.test(value) ? `${value}:00` : value
}
function updateLocalInput(field: 'from' | 'to', value: string): void {
emit('update:preset', undefined)
const normalized = normalizeLocalInputValue(value)
if (field === 'from') {
emit('update:from', normalized)
@@ -50,23 +72,34 @@ function updateLocalInput(field: 'from' | 'to', value: string): void {
}
}
function localInputValue(milliseconds: number): string {
const date = new Date(milliseconds)
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
function selectShortcut(preset: DateTimePreset): void {
const now = Math.floor(Date.now() / 1000) * 1000
const range = resolveDateTimePreset(preset, now, props.rollingEndOffsetMs ?? 24 * 60 * 60 * 1000)
emit('update:from', localDateTimeInput(range.from_ms))
emit('update:to', localDateTimeInput(range.to_ms))
emit('update:preset', preset)
emit('shortcut', preset, range.from_ms, range.to_ms)
if (props.applyLabel && range.to_ms > range.from_ms) open.value = false
}
function selectShortcut(milliseconds: number): void {
const now = Math.floor(Date.now() / 1000) * 1000
emit('update:from', localInputValue(Math.max(0, now - milliseconds)))
emit('update:to', localInputValue(now + 24 * 60 * 60 * 1000))
function apply(): void {
if (props.applyDisabled) return
emit('apply')
open.value = false
}
</script>
<template>
<AppPopover v-model:open="open" align="start" content-class="app-date-range-popover">
<AppPopover
:open="open"
align="start"
:content-class="
applyLabel
? 'app-date-range-popover app-date-range-popover--with-apply'
: 'app-date-range-popover'
"
@update:open="changeOpen"
>
<template #trigger>
<AppButton
class="app-date-range__trigger"
@@ -83,21 +116,25 @@ function selectShortcut(milliseconds: number): void {
<div class="app-date-range__shortcuts" :aria-label="t('monitor.logs.filters.quickRanges')">
<AppButton
v-for="shortcut in shortcuts"
:key="shortcut.key"
v-for="shortcut in dateTimePresets"
:key="shortcut"
variant="ghost"
size="compact"
@click="selectShortcut(shortcut.milliseconds)"
:aria-pressed="preset === shortcut"
@click="selectShortcut(shortcut)"
>
{{ t(`monitor.logs.filters.quick.${shortcut.key}`) }}
{{ t(`monitor.logs.filters.quick.${shortcut}`) }}
</AppButton>
</div>
<div class="app-date-range__fields">
<FormField id="logs-range-from" :label="fromLabel" size="compact" :error="fromError">
<div
class="app-date-range__fields"
:class="{ 'app-date-range__fields--with-apply': applyLabel }"
>
<FormField :id="`${fieldID}-from`" :label="fromLabel" size="compact" :error="fromError">
<template #default="{ describedBy, invalid }">
<span class="app-date-range__input-shell">
<input
id="logs-range-from"
:id="`${fieldID}-from`"
class="app-date-range__native-input"
:value="from"
type="datetime-local"
@@ -110,25 +147,41 @@ function selectShortcut(milliseconds: number): void {
</span>
</template>
</FormField>
<FormField id="logs-range-to" :label="toLabel" size="compact" :error="toError">
<FormField
:id="`${fieldID}-to`"
class="app-date-range__end-field"
:label="toLabel"
size="compact"
:error="toError"
>
<template #default="{ describedBy, invalid }">
<span class="app-date-range__input-shell">
<input
id="logs-range-to"
class="app-date-range__native-input"
:value="to"
type="datetime-local"
step="1"
:aria-label="toLabel"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
@input="updateLocalInput('to', ($event.target as HTMLInputElement).value)"
/>
</span>
<div class="app-date-range__end-controls">
<span class="app-date-range__input-shell">
<input
:id="`${fieldID}-to`"
class="app-date-range__native-input"
:value="to"
type="datetime-local"
step="1"
:aria-label="toLabel"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
@input="updateLocalInput('to', ($event.target as HTMLInputElement).value)"
/>
</span>
<AppButton
v-if="applyLabel"
class="app-date-range__apply"
size="compact"
:disabled="applyDisabled"
@click="apply"
>
{{ applyLabel }}
</AppButton>
</div>
</template>
</FormField>
</div>
<p class="app-date-range__timezone">{{ timezoneLabel }} · {{ timezone }}</p>
</AppPopover>
</template>
@@ -149,20 +202,37 @@ function selectShortcut(milliseconds: number): void {
padding: 14px;
}
.app-date-range-popover--with-apply {
width: min(480px, var(--reka-popover-content-available-width));
}
.app-date-range__shortcuts {
display: flex;
min-width: 0;
flex-wrap: nowrap;
gap: 4px;
gap: 2px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: thin;
border-bottom: 1px solid var(--color-border-subtle);
padding-bottom: 10px;
}
.app-date-range__shortcuts .app-button {
flex: 1 0 auto;
padding-inline: 6px;
white-space: nowrap;
}
.app-date-range__shortcuts .app-button[aria-pressed='true'] {
background: var(--color-action-soft);
color: var(--color-action);
}
.app-date-range__end-controls .app-date-range__apply {
min-height: var(--control-xs);
}
.app-date-range__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -170,10 +240,28 @@ function selectShortcut(milliseconds: number): void {
padding-top: 12px;
}
.app-date-range__timezone {
margin: 10px 0 0;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
.app-date-range__fields--with-apply {
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
.app-date-range__fields--with-apply .app-date-range__end-field {
grid-column: 2 / -1;
grid-template-columns: subgrid;
}
.app-date-range__end-controls {
display: grid;
min-width: 0;
align-items: center;
}
.app-date-range__fields--with-apply .app-date-range__end-controls {
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.app-date-range__fields--with-apply .app-date-range__end-field > .form-field__error {
grid-column: 1 / -1;
}
.app-date-range__input-shell {
@@ -233,5 +321,11 @@ function selectShortcut(milliseconds: number): void {
.app-date-range__fields {
grid-template-columns: minmax(0, 1fr);
}
.app-date-range__fields--with-apply .app-date-range__end-field {
grid-column: 1;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 10px;
}
}
</style>
@@ -52,8 +52,8 @@ function viewUsage(id: number): void {
monitorLocation({
tab: 'usage',
access_key_id: String(id),
range: props.usageWindow.range,
at_ms: String(props.usageWindow.observed_at_ms),
from_ms: String(props.usageWindow.from_ms),
to_ms: String(props.usageWindow.to_ms),
}),
)
}
@@ -64,8 +64,6 @@ function viewLogs(id: number): void {
access_key_id: String(id),
from_ms: String(props.usageWindow.from_ms),
to_ms: String(props.usageWindow.to_ms),
usage_range: props.usageWindow.range,
usage_at_ms: String(props.usageWindow.observed_at_ms),
}),
)
}
@@ -105,7 +105,6 @@ function applyAdvanced(): void {
:label="t('monitor.logs.filters.timeRange')"
:from-label="t('monitor.logs.filters.from')"
:to-label="t('monitor.logs.filters.to')"
:timezone-label="t('monitor.logs.filters.timezone')"
:from-error="errors.from ? t(errors.from) : undefined"
:to-error="errors.to ? t(errors.to) : undefined"
@update:from="update('from', $event)"
+3 -7
View File
@@ -360,9 +360,7 @@ async function commitFilters(filters: RequestLogFilters): Promise<void> {
logsMonitorQuery(filters, {
filtersOpen: false,
cursorHistory: [],
...(sameWindow
? { usageAtMS: routeState.value.usageAtMS, usageRange: routeState.value.usageRange }
: {}),
...(sameWindow ? { usagePreset: routeState.value.usagePreset } : {}),
}),
),
)
@@ -430,8 +428,7 @@ function nextPage(): void {
monitorLocation(
logsMonitorQuery(appliedFilters.value, {
filtersOpen: false,
usageAtMS: routeState.value.usageAtMS,
usageRange: routeState.value.usageRange,
usagePreset: routeState.value.usagePreset,
cursorHistory: [...routeState.value.cursorHistory, cursor],
}),
),
@@ -449,8 +446,7 @@ function previousPage(): void {
monitorLocation(
logsMonitorQuery(appliedFilters.value, {
filtersOpen: false,
usageAtMS: routeState.value.usageAtMS,
usageRange: routeState.value.usageRange,
usagePreset: routeState.value.usagePreset,
cursorHistory: routeState.value.cursorHistory.slice(0, -1),
}),
),
+96 -30
View File
@@ -6,15 +6,15 @@ import { useRoute, useRouter } from 'vue-router'
import { lazySurface } from '@/app/async-surface'
import { monitorLocation } from '@/app/route-locations'
import { usageRanges, type UsageReportDto } from '@/app/resources/usage'
import type { UsageReportDto } from '@/app/resources/usage'
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
import PageFrame from '@/components/layout/PageFrame.vue'
import AppButton from '@/components/ui/AppButton.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import AppDateTimeRangePicker from '@/components/ui/AppDateTimeRangePicker.vue'
import AppTabs, { type AppTabItem } from '@/components/ui/AppTabs.vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import { isTimeRange } from '@/lib/time'
import { defaultRequestLogFilters, parseAppliedLogFilters } from './log-filters'
import { localDateTimeInput, parseLocalDateTime, type DateTimePreset } from '@/lib/time'
import { parseAppliedLogFilters } from './log-filters'
import { useAuthSession } from '@/features/auth/auth-session'
import HealthTab from './HealthTab.vue'
@@ -77,11 +77,41 @@ const usageFilters = computed(() => {
const filters = parseAppliedUsageFilters(route.query)
return isAccessKey.value ? scopeAccessKeyUsageFilters(filters) : filters
})
const usageRangeOptions = computed(() =>
usageRanges.map((value) => ({
value,
label: t(`monitor.usage.filters.ranges.${value}`),
})),
const usageTimeDraft = ref<{ from: string; to: string; preset?: DateTimePreset }>({
from: '',
to: '',
})
const usageTimeValues = computed(() => {
const draft = usageTimeDraft.value
const current = usageFilters.value
// 回拨时同一本地时间对应两个时刻,未改动的端点保留原始毫秒值。
return {
from:
draft.from !== '' && draft.from === localDateTimeInput(current.from_ms)
? current.from_ms
: parseLocalDateTime(draft.from)?.getTime(),
to:
draft.to !== '' && draft.to === localDateTimeInput(current.to_ms)
? current.to_ms
: parseLocalDateTime(draft.to)?.getTime(),
}
})
const usageTimeErrors = computed(() => {
const { from, to } = usageTimeValues.value
return {
from: from === undefined ? t('monitor.logs.errors.dateTime') : undefined,
to:
to === undefined
? t('monitor.logs.errors.dateTime')
: from !== undefined && to <= from
? t('monitor.logs.errors.range')
: undefined,
}
})
watch(
() => [usageFilters.value.from_ms, usageFilters.value.to_ms, usageFilters.value.preset],
resetUsageTimeDraft,
{ immediate: true },
)
const usageFilterCount = computed(
() =>
@@ -110,9 +140,9 @@ function selectTab(value: string): void {
if (activeTab.value === 'usage' && tab === 'logs') {
if (usageTab.value?.navigationPending) return
const report = usageTab.value?.navigationReport
// 用量不可用时使用日志默认时间范围,仍保留筛选和返回用量的上下文。
const logRange = report ?? defaultRequestLogFilters()
const filters = usageFilters.value
// 显式起止时间已经确定,报告不可用时仍可保持同一查询区间。
const logRange = report ?? filters
void router.push(
monitorLocation(
logsMonitorQuery(
@@ -129,8 +159,7 @@ function selectTab(value: string): void {
{
filtersOpen: false,
cursorHistory: [],
usageRange: filters.range,
usageAtMS: filters.at_ms ?? report?.observed_at_ms,
usagePreset: filters.preset,
},
),
),
@@ -142,15 +171,7 @@ function selectTab(value: string): void {
const previous = parseLogsMonitorState(route.query)
void router.push(
monitorLocation(
usageMonitorQuery({
range: previous.usageRange ?? '24h',
at_ms: previous.usageAtMS,
access_key_id: filters.access_key_id,
group_id: filters.group_id,
channel_id: filters.channel_id,
credential_id: filters.credential_id,
upstream_model: filters.upstream_model,
}),
usageMonitorQuery(parseAppliedUsageFilters({ ...filters, preset: previous.usagePreset })),
),
)
return
@@ -178,13 +199,31 @@ async function refreshUsage(): Promise<void> {
}
}
function selectUsageRange(value: string): void {
if (!isTimeRange(value)) return
function selectUsageShortcut(preset: DateTimePreset, from: number, to: number): void {
if (to <= from) return
applyUsageTimeRange(from, to, preset)
}
function resetUsageTimeDraft(): void {
usageTimeDraft.value = {
from: localDateTimeInput(usageFilters.value.from_ms),
to: localDateTimeInput(usageFilters.value.to_ms),
preset: usageFilters.value.preset,
}
}
function applyUsageCustomTime(): void {
const { from, to } = usageTimeValues.value
if (from === undefined || to === undefined || to <= from) return
applyUsageTimeRange(from, to, usageTimeDraft.value.preset)
}
function applyUsageTimeRange(from: number, to: number, preset?: DateTimePreset): void {
const state = parseUsageMonitorState(route.query)
void router.push(
monitorLocation(
usageMonitorQuery(
{ ...usageFilters.value, range: value, at_ms: undefined },
{ ...usageFilters.value, from_ms: from, to_ms: to, preset },
{
filtersOpen: false,
seriesExpanded: false,
@@ -202,6 +241,7 @@ function selectUsageRange(value: string): void {
<PageHeader id="monitor-title" :title="t('monitor.title')" />
<AppTabs
class="monitor-tabs"
:class="{ 'monitor-tabs--usage': activeTab === 'usage' }"
:model-value="activeTab"
:label="t('monitor.tabs.label')"
:items="items"
@@ -225,12 +265,23 @@ function selectUsageRange(value: string): void {
{{ t('monitor.health.refresh') }}
</AppButton>
<div v-else-if="activeTab === 'usage'" class="monitor-usage-actions">
<AppSelect
:model-value="usageFilters.range"
<AppDateTimeRangePicker
v-model:from="usageTimeDraft.from"
v-model:to="usageTimeDraft.to"
v-model:preset="usageTimeDraft.preset"
:applied-from="localDateTimeInput(usageFilters.from_ms)"
:applied-to="localDateTimeInput(usageFilters.to_ms)"
:label="t('monitor.usage.filters.range')"
:options="usageRangeOptions"
size="compact"
@update:model-value="selectUsageRange"
:from-label="t('monitor.logs.filters.from')"
:to-label="t('monitor.logs.filters.to')"
:from-error="usageTimeErrors.from"
:to-error="usageTimeErrors.to"
:rolling-end-offset-ms="0"
:apply-label="t('monitor.usage.filters.apply')"
:apply-disabled="Boolean(usageTimeErrors.from || usageTimeErrors.to)"
@shortcut="selectUsageShortcut"
@apply="applyUsageCustomTime"
@open="resetUsageTimeDraft"
/>
<AppButton variant="secondary" size="compact" @click="usageTab?.openFilters()">
<ListFilter :size="14" aria-hidden="true" />
@@ -334,12 +385,27 @@ function selectUsageRange(value: string): void {
.monitor-panel {
padding-top: var(--detail-panel-padding-top-compact);
}
.monitor-tabs--usage :deep(.app-tabs__bar) {
flex-wrap: wrap;
}
.monitor-tabs--usage :deep(.app-tabs__list),
.monitor-tabs--usage :deep(.app-tabs__actions) {
width: 100%;
}
}
@media (max-width: 560px) {
.monitor-usage-actions {
width: 100%;
flex-wrap: wrap;
gap: var(--space-1);
}
.monitor-usage-actions :deep(.app-popover) {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
+1 -1
View File
@@ -116,7 +116,7 @@ watch(seriesKey, () => {
})
function formatBucketTime(datum: UsageBarDatum): string {
return formatLocalTimeRange(datum.bucket_start_ms, datum.bucket_end_ms, props.locale)
return formatLocalTimeRange(datum.bucket_start_ms, datum.bucket_end_ms, props.locale, true)
}
function primaryTooltipValue(datum: UsageBarDatum): string {
+51 -21
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { Database } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -16,14 +16,11 @@ import {
type UsageAggregateDto,
type UsageDistributionDimension,
type UsageDistributionMetric,
type UsageFilters,
type UsageRange,
type UsageReportDto,
} from '@/app/resources/usage'
import { monitorLocation } from '@/app/route-locations'
import TrendChart from '@/components/charts/TrendChart.vue'
import type { TrendDatum } from '@/components/charts/trend-chart'
import AppDateTime from '@/components/ui/AppDateTime.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import AsyncRefreshIndicator from '@/components/ui/AsyncRefreshIndicator.vue'
import DataTable from '@/components/ui/DataTable.vue'
@@ -32,7 +29,13 @@ import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import SkeletonSurface from '@/components/ui/SkeletonSurface.vue'
import { formatEstimatedCost, formatInteger, formatTokens } from '@/lib/format'
import {
formatEstimatedCost,
formatInteger,
formatTokens,
formatLocalTimeRange,
} from '@/lib/format'
import { resolveDateTimePreset } from '@/lib/time'
import { useAuthSession } from '@/features/auth/auth-session'
import MonitorSectionHeading from './MonitorSectionHeading.vue'
@@ -43,6 +46,7 @@ import {
createUsageFilterDraft,
parseAppliedUsageFilters,
validateUsageFilterDraft,
type AppliedUsageFilters,
type UsageFilterDraft,
type UsageFilterErrors,
} from './usage-filters'
@@ -224,12 +228,12 @@ function normalizeTrendCost(value: bigint, maximum: bigint): number {
const trendBuckets = computed<UsageReportDto['series']>(() => {
const current = report.value
if (current?.range !== '1h') return current?.series ?? []
if (!current) return []
const byStart = new Map(current.series.map((bucket) => [bucket.bucket_start_ms, bucket]))
const buckets: UsageReportDto['series'] = []
const width = current.bucket_width_ms
// 补齐自然五分钟桶,首尾只覆盖最近一小时内的部分。
// 使用后端返回的粒度补零,首尾裁切到查询范围,不推断残缺桶的宽度。
for (
let alignedStart = Math.floor(current.from_ms / width) * width;
alignedStart < current.to_ms;
@@ -294,7 +298,9 @@ const barTrendSeries = computed<UsageBarDatum[]>(() => {
watch(
[
() => appliedFilters.value.range,
() => appliedFilters.value.from_ms,
() => appliedFilters.value.to_ms,
() => appliedFilters.value.preset,
() => appliedFilters.value.access_key_id,
() => appliedFilters.value.group_id,
() => appliedFilters.value.channel_id,
@@ -315,10 +321,6 @@ watch(
},
)
function rangeLabel(range: UsageRange): string {
return t(`monitor.usage.filters.ranges.${range}`)
}
function granularityLabel(): string {
const bucketWidthMS = report.value?.bucket_width_ms
if (report.value?.granularity === 'minute') {
@@ -326,6 +328,11 @@ function granularityLabel(): string {
}
if (bucketWidthMS === 60 * 60 * 1000) return t('monitor.usage.trend.hourly')
if (bucketWidthMS === 24 * 60 * 60 * 1000) return t('monitor.usage.trend.daily')
if (report.value?.granularity === 'day') {
return t('monitor.usage.trend.everyDays', {
count: (bucketWidthMS ?? 0) / (24 * 60 * 60 * 1000),
})
}
return t('monitor.usage.trend.everyHours', {
count: (bucketWidthMS ?? 0) / (60 * 60 * 1000),
})
@@ -367,11 +374,15 @@ async function applyFilters(): Promise<void> {
const errors = validateUsageFilterDraft(draft.value)
filterErrors.value = errors
if (Object.keys(errors).length > 0) return
await navigate({ ...applyUsageFilterDraft(draft.value), at_ms: appliedFilters.value.at_ms })
await navigate(applyUsageFilterDraft(draft.value, appliedFilters.value))
}
async function resetFilters(): Promise<void> {
await navigate({ range: appliedFilters.value.range })
await navigate({
from_ms: appliedFilters.value.from_ms,
to_ms: appliedFilters.value.to_ms,
preset: appliedFilters.value.preset,
})
}
function updateDistributionDimension(value: string): void {
@@ -399,7 +410,7 @@ async function updateTrendMetric(value: string): Promise<void> {
}
async function navigate(
filters: UsageFilters,
filters: AppliedUsageFilters,
state: UsageMonitorState = {
filtersOpen: false,
seriesExpanded: false,
@@ -417,8 +428,18 @@ function setSeriesExpanded(event: Event): void {
}
async function refresh(): Promise<void> {
const filters = appliedFilters.value
if (filters.preset) {
const interval = resolveDateTimePreset(filters.preset, Math.floor(Date.now() / 1000) * 1000)
if (interval.to_ms > interval.from_ms) {
await router.replace(
monitorLocation(usageMonitorQuery({ ...filters, ...interval }, routeState.value)),
)
await nextTick()
}
}
await Promise.all([
usageQuery.refetch(),
usageQuery.refetch({ cancelRefetch: false }),
...(!isAccessKey.value
? [groupsQuery.refetch(), channelsQuery.refetch(), accessKeysQuery.refetch()]
: []),
@@ -501,7 +522,7 @@ defineExpose({ openFilters, refresh, navigationReport, navigationPending })
id="usage-trend-title"
:title="trendPresentation.title"
:description="trendPresentation.description"
:meta="`${rangeLabel(report.range)} · ${granularityLabel()}`"
:meta="granularityLabel()"
>
<template #actions>
<SegmentedControl
@@ -524,7 +545,8 @@ defineExpose({ openFilters, refresh, navigationReport, navigationPending })
:failure-label="trendPresentation.secondaryLabel ?? ''"
:range-start="report.from_ms"
:range-end="report.to_ms"
:center-buckets="report.range === '1h'"
center-buckets
show-bucket-seconds
:locale="locale"
show-bucket-range
show-single-point
@@ -651,7 +673,6 @@ defineExpose({ openFilters, refresh, navigationReport, navigationPending })
navigate({
...appliedFilters,
access_key_id: $event,
at_ms: appliedFilters.at_ms ?? report.observed_at_ms,
})
"
/>
@@ -677,8 +698,17 @@ defineExpose({ openFilters, refresh, navigationReport, navigationPending })
</tr>
</thead>
<tbody>
<tr v-for="bucket in report.series" :key="bucket.bucket_start_ms">
<td><AppDateTime :instant="bucket.bucket_start_ms" :locale="locale" /></td>
<tr v-for="bucket in trendBuckets" :key="bucket.bucket_start_ms">
<td>
{{
formatLocalTimeRange(
bucket.bucket_start_ms,
bucket.bucket_end_ms,
locale,
true,
)
}}
</td>
<td>{{ formatInteger(bucket.request_count, locale) }}</td>
<td>{{ formatInteger(bucket.success_count, locale) }}</td>
<td>{{ formatInteger(bucket.failure_count, locale) }}</td>
+11 -18
View File
@@ -3,7 +3,7 @@ import type { LocationQueryRaw } from 'vue-router'
import { enabledDataProtocols } from '@/api/control/protocols'
import type { UsageFilters } from '@/app/resources/usage'
import type { RequestLogFilters } from '@/app/resources/request-logs'
import { defaultTimeRange } from '@/lib/time'
import { isDateTimePreset, type DateTimePreset } from '@/lib/time'
import { parseAppliedLogFilters, serializeAppliedLogFilters } from './log-filters'
import {
@@ -11,6 +11,8 @@ import {
normalizeUsageChannelID,
normalizeUsageModel,
parseAppliedUsageFilters,
defaultUsageFilters,
type AppliedUsageFilters,
} from './usage-filters'
import { normalizeMonitorText } from './filter-validation'
@@ -28,8 +30,7 @@ export interface UsageMonitorState {
}
export interface LogsMonitorState {
usageAtMS?: number
usageRange?: UsageFilters['range']
usagePreset?: DateTimePreset
filtersOpen: boolean
cursorHistory: string[]
selectedRequestID?: string
@@ -76,7 +77,7 @@ const accessKeyForbiddenLogFilters: readonly (keyof RequestLogFilters)[] = [
'retry_count_max',
]
export function scopeAccessKeyUsageFilters(filters: UsageFilters): UsageFilters {
export function scopeAccessKeyUsageFilters<T extends UsageFilters>(filters: T): T {
const scoped = { ...filters }
delete scoped.access_key_id
delete scoped.group_id
@@ -114,7 +115,7 @@ export function healthMonitorQuery(state: HealthMonitorState): LocationQueryRaw
}
export function usageMonitorQuery(
filters: UsageFilters = { range: defaultTimeRange },
filters: AppliedUsageFilters = defaultUsageFilters(),
state: UsageMonitorState = {
filtersOpen: false,
seriesExpanded: false,
@@ -123,10 +124,11 @@ export function usageMonitorQuery(
): LocationQueryRaw {
const normalized: LocationQueryRaw = {
tab: 'usage',
range: filters.range,
from_ms: String(filters.from_ms),
to_ms: String(filters.to_ms),
metric: state.metric,
}
if (filters.at_ms !== undefined) normalized.at_ms = String(filters.at_ms)
if (filters.preset) normalized.preset = filters.preset
const accessKeyID = normalizeUsageGroupID(filters.access_key_id)
const groupID = normalizeUsageGroupID(filters.group_id)
const channelID = normalizeUsageChannelID(filters.channel_id)
@@ -206,11 +208,7 @@ export function inspectorMonitorQuery(state: InspectorMonitorState): LocationQue
export function parseLogsMonitorState(query: Record<string, unknown>): LogsMonitorState {
return {
usageAtMS: normalizeUsageGroupID(query.usage_at_ms),
usageRange:
query.usage_range === undefined
? undefined
: parseAppliedUsageFilters({ range: query.usage_range }).range,
usagePreset: isDateTimePreset(query.usage_preset) ? query.usage_preset : undefined,
filtersOpen: query.panel === 'filters',
cursorHistory: parseLogCursorHistory(query.log_cursors),
selectedRequestID: parseSelectedRequestID(query),
@@ -222,12 +220,7 @@ export function logsMonitorQuery(
state: LogsMonitorState = { filtersOpen: false, cursorHistory: [] },
): LocationQueryRaw {
const normalized = serializeAppliedLogFilters(filters)
if (state.usageAtMS !== undefined) {
normalized.usage_at_ms = String(state.usageAtMS)
}
if (state.usageRange !== undefined || state.usageAtMS !== undefined) {
normalized.usage_range = state.usageRange ?? defaultTimeRange
}
if (state.usagePreset !== undefined) normalized.usage_preset = state.usagePreset
if (state.filtersOpen) normalized.panel = 'filters'
const cursorHistory = serializeLogCursorHistory(state.cursorHistory)
if (cursorHistory !== undefined) normalized.log_cursors = cursorHistory
+41 -14
View File
@@ -1,10 +1,18 @@
import type { UsageFilters } from '@/app/resources/usage'
import { defaultTimeRange, isTimeRange } from '@/lib/time'
import {
defaultTimeRange,
isDateTimePreset,
resolveDateTimePreset,
type DateTimePreset,
} from '@/lib/time'
import { normalizeMonitorText } from './filter-validation'
export interface AppliedUsageFilters extends UsageFilters {
preset?: DateTimePreset
}
export interface UsageFilterDraft {
range: UsageFilters['range']
access_key_id: string
group_id: string
channel_id: string
@@ -12,10 +20,9 @@ export interface UsageFilterDraft {
upstream_model: string
}
export type UsageFilterErrors = Partial<Record<Exclude<keyof UsageFilterDraft, 'range'>, string>>
export type UsageFilterErrors = Partial<Record<keyof UsageFilterDraft, string>>
const emptyDraft = (): UsageFilterDraft => ({
range: defaultTimeRange,
access_key_id: '',
group_id: '',
channel_id: '',
@@ -23,8 +30,20 @@ const emptyDraft = (): UsageFilterDraft => ({
upstream_model: '',
})
export function normalizeUsageRange(raw: unknown): UsageFilters['range'] {
return isTimeRange(raw) ? raw : defaultTimeRange
export function defaultUsageFilters(
preset: DateTimePreset = defaultTimeRange,
): AppliedUsageFilters {
const now = Math.floor(Date.now() / 1000) * 1000
const interval = resolveDateTimePreset(preset, now)
return interval.to_ms > interval.from_ms
? { ...interval, preset }
: { ...resolveDateTimePreset(defaultTimeRange, now), preset: defaultTimeRange }
}
function normalizeUsageTimestamp(raw: unknown): number | undefined {
const value =
typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : NaN
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
export function normalizeUsageGroupID(raw: unknown): number | undefined {
@@ -46,10 +65,14 @@ export function normalizeUsageChannelID(raw: unknown): string | undefined {
return raw
}
export function parseAppliedUsageFilters(query: Record<string, unknown>): UsageFilters {
const filters: UsageFilters = { range: normalizeUsageRange(query.range) }
const at = normalizeUsageGroupID(query.at_ms)
if (at !== undefined) filters.at_ms = at
export function parseAppliedUsageFilters(query: Record<string, unknown>): AppliedUsageFilters {
const from = normalizeUsageTimestamp(query.from_ms)
const to = normalizeUsageTimestamp(query.to_ms)
const preset = query.preset ?? query.range
const filters: AppliedUsageFilters =
from !== undefined && to !== undefined && to > from
? { from_ms: from, to_ms: to, ...(isDateTimePreset(preset) ? { preset } : {}) }
: defaultUsageFilters(isDateTimePreset(preset) ? preset : defaultTimeRange)
const accessKeyID = normalizeUsageGroupID(query.access_key_id)
const groupID = normalizeUsageGroupID(query.group_id)
const channelID = normalizeUsageChannelID(query.channel_id)
@@ -66,7 +89,6 @@ export function parseAppliedUsageFilters(query: Record<string, unknown>): UsageF
export function createUsageFilterDraft(filters: UsageFilters): UsageFilterDraft {
return {
...emptyDraft(),
range: filters.range,
access_key_id: filters.access_key_id === undefined ? '' : String(filters.access_key_id),
group_id: filters.group_id === undefined ? '' : String(filters.group_id),
channel_id: filters.channel_id ?? '',
@@ -75,9 +97,14 @@ export function createUsageFilterDraft(filters: UsageFilters): UsageFilterDraft
}
}
export function applyUsageFilterDraft(draft: UsageFilterDraft): UsageFilters {
const filters: UsageFilters = {
range: normalizeUsageRange(draft.range),
export function applyUsageFilterDraft(
draft: UsageFilterDraft,
current: AppliedUsageFilters,
): AppliedUsageFilters {
const filters: AppliedUsageFilters = {
from_ms: current.from_ms,
to_ms: current.to_ms,
preset: current.preset,
}
const accessKeyID = normalizeUsageGroupID(draft.access_key_id)
const groupID = normalizeUsageGroupID(draft.group_id)
+4
View File
@@ -342,6 +342,7 @@ export default {
hourly: 'Hourly',
everyMinutes: 'Every {count} minutes',
everyHours: 'Every {count} hours',
everyDays: 'Every {count} days',
daily: 'Daily',
failureRate: 'Failure rate',
inputTokens: 'Input tokens',
@@ -604,6 +605,9 @@ export default {
to: 'To',
quickRanges: 'Quick time ranges',
quick: {
today: 'Today',
yesterday: 'Yesterday',
'6h': '6h',
'1h': '1h',
'24h': '24h',
'3d': '3d',
+4
View File
@@ -342,6 +342,7 @@ export default {
hourly: '時間別',
everyMinutes: '{count}分ごと',
everyHours: '{count}時間ごと',
everyDays: '{count} 日ごと',
daily: '日別',
failureRate: '失敗率',
inputTokens: '入力 Token',
@@ -603,6 +604,9 @@ export default {
to: '終了時刻',
quickRanges: 'クイック時間範囲',
quick: {
today: '今日',
yesterday: '昨日',
'6h': '6h',
'1h': '1h',
'24h': '24h',
'3d': '3d',
+4
View File
@@ -326,6 +326,7 @@ export default {
hourly: '按小时',
everyMinutes: '每 {count} 分钟',
everyHours: '每 {count} 小时',
everyDays: '每 {count} 天',
daily: '按天',
failureRate: '失败率',
inputTokens: '输入 Token',
@@ -582,6 +583,9 @@ export default {
to: '结束时间',
quickRanges: '快捷时间范围',
quick: {
today: '今天',
yesterday: '昨天',
'6h': '6h',
'1h': '1h',
'24h': '24h',
'3d': '3d',
+13 -6
View File
@@ -50,17 +50,24 @@ export function formatLocalInstantWithSeconds(ms: number, timeZone = currentTime
return date ? formatZonedDateTime(date, timeZone, true) : '—'
}
export function formatLocalTimeRange(startMs: number, endMs: number, locale: string): string {
export function formatLocalTimeRange(
startMs: number,
endMs: number,
locale: string,
includeSeconds = false,
): string {
void locale
const start = validDate(startMs)
const end = validDate(endMs)
if (!start || !end || end.getTime() <= start.getTime()) return '—'
const timeZone = currentTimeZone()
return `${formatZonedDateTime(start, timeZone, false)} – ${formatZonedDateTime(
end,
timeZone,
false,
)}`
const format = (date: Date) => {
const value = formatZonedDateTime(date, timeZone, includeSeconds)
return includeSeconds && date.getMilliseconds()
? `${value}.${String(date.getMilliseconds()).padStart(3, '0')}`
: value
}
return `${format(start)} – ${format(end)}`
}
export function formatDuration(startedAtMs: number, nowMs: number, locale: string): string {
+59 -1
View File
@@ -1,5 +1,7 @@
export const timeRanges = ['1h', '24h', '3d', '7d', '15d', '30d'] as const
export const timeRanges = ['1h', '6h', '24h', '3d', '7d', '15d', '30d'] as const
export type TimeRange = (typeof timeRanges)[number]
export const dateTimePresets = ['today', 'yesterday', ...timeRanges] as const
export type DateTimePreset = (typeof dateTimePresets)[number]
export const defaultTimeRange: TimeRange = '24h'
@@ -7,6 +9,7 @@ const hourMilliseconds = 60 * 60 * 1000
export const timeRangeMilliseconds: Record<TimeRange, number> = {
'1h': hourMilliseconds,
'6h': 6 * hourMilliseconds,
'24h': 24 * hourMilliseconds,
'3d': 3 * 24 * hourMilliseconds,
'7d': 7 * 24 * hourMilliseconds,
@@ -18,6 +21,61 @@ export function isTimeRange(value: unknown): value is TimeRange {
return typeof value === 'string' && timeRanges.some((range) => range === value)
}
export function isDateTimePreset(value: unknown): value is DateTimePreset {
return value === 'today' || value === 'yesterday' || isTimeRange(value)
}
export function resolveDateTimePreset(
preset: DateTimePreset,
now = Date.now(),
rollingEndOffsetMs = 0,
): { from_ms: number; to_ms: number } {
if (preset === 'today' || preset === 'yesterday') {
const midnight = new Date(now)
midnight.setHours(0, 0, 0, 0)
if (preset === 'today') return { from_ms: midnight.getTime(), to_ms: now }
const to = midnight.getTime()
midnight.setDate(midnight.getDate() - 1)
return { from_ms: midnight.getTime(), to_ms: to }
}
return {
from_ms: Math.max(0, now - timeRangeMilliseconds[preset]),
to_ms: now + rollingEndOffsetMs,
}
}
export function localDateTimeInput(milliseconds: number): string {
const date = new Date(milliseconds)
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || Number.isNaN(date.getTime())) {
return ''
}
const pad = (part: number, length = 2) => String(part).padStart(length, '0')
const fraction = date.getMilliseconds() ? `.${pad(date.getMilliseconds(), 3)}` : ''
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${fraction}`
}
export function parseLocalDateTime(value: string): Date | undefined {
const match = value.match(/^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/u)
if (!match) return undefined
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number)
const milliseconds = Number((match[7] ?? '').padEnd(3, '0'))
const date = new Date(0)
date.setFullYear(year!, month! - 1, day!)
date.setHours(hour!, minute!, second!, milliseconds)
return date.getTime() >= 0 &&
date.getFullYear() === year &&
date.getMonth() === month! - 1 &&
date.getDate() === day &&
date.getHours() === hour &&
date.getMinutes() === minute &&
date.getSeconds() === second &&
date.getMilliseconds() === milliseconds
? date
: undefined
}
export function currentTimeZone(): string {
try {
return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'