feat(usage): 支持自定义时间统计并统一时间筛选 (#594)
* feat(usage): 统一时间区间统计与快捷筛选 * fix(usage): 精简时间筛选与趋势提示布局 * fix(usage): 保留未修改时间端点的原始时刻
This commit is contained in:
1 parent
a97578fe9b
commit
175949e20a
35 files changed
+1353
-795
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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" `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user