Track server clock offset for usage data

This commit is contained in:
Novick Yuan
2026-05-28 14:30:15 +08:00
parent 8aca1f8b93
commit 6de684df59
7 changed files with 178 additions and 12 deletions
+12 -3
View File
@@ -5,6 +5,11 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
import type { FeatureSettingsMap } from '@/utils/featureSettings'
import {
beginServerTimingSample,
withServerTiming,
type ServerTimedPayload,
} from './serverTiming'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
@@ -142,7 +147,8 @@ export interface ApiFormatSummary {
}
// 使用统计响应接口
export interface UsageResponse {
export interface UsageResponse extends ServerTimedPayload {
server_now_unix_ms?: number
total_requests: number
total_input_tokens: number
total_output_tokens: number
@@ -321,12 +327,14 @@ export const meApi = {
limit?: number
offset?: number
}): Promise<UsageResponse> {
const clientSendUnixMs = beginServerTimingSample()
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
return withServerTiming(response.data, clientSendUnixMs)
},
// 获取活跃请求状态(用于轮询更新)
async getActiveRequests(ids?: string): Promise<{
server_timing?: ServerTimedPayload['server_timing']
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
@@ -358,8 +366,9 @@ export const meApi = {
}>
}> {
const params = ids ? { ids } : {}
const clientSendUnixMs = beginServerTimingSample()
const response = await apiClient.get('/api/users/me/usage/active', { params })
return response.data
return withServerTiming(response.data, clientSendUnixMs)
},
// 获取可用的提供商
+44
View File
@@ -0,0 +1,44 @@
export interface ServerTimingMetadata {
server_now_unix_ms: number
client_send_unix_ms: number
client_receive_unix_ms: number
}
export interface ServerTimedPayload {
server_timing?: ServerTimingMetadata
}
export function beginServerTimingSample(): number {
return Date.now()
}
export function readServerNowUnixMs(payload: unknown): number | null {
if (!payload || typeof payload !== 'object') return null
const value = (payload as { server_now_unix_ms?: unknown }).server_now_unix_ms
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export function buildServerTimingMetadata(
payload: unknown,
clientSendUnixMs: number,
clientReceiveUnixMs = Date.now()
): ServerTimingMetadata | undefined {
const serverNowUnixMs = readServerNowUnixMs(payload)
if (serverNowUnixMs == null) return undefined
if (!Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) return undefined
return {
server_now_unix_ms: serverNowUnixMs,
client_send_unix_ms: clientSendUnixMs,
client_receive_unix_ms: clientReceiveUnixMs,
}
}
export function withServerTiming<T extends object>(payload: T, clientSendUnixMs: number): T & ServerTimedPayload {
const serverTiming = buildServerTimingMetadata(payload, clientSendUnixMs)
if (!serverTiming) return payload
return {
...payload,
server_timing: serverTiming,
}
}
+23 -5
View File
@@ -2,6 +2,11 @@ import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
import type { ImageProgress } from './requestTrace'
import {
beginServerTimingSample,
withServerTiming,
type ServerTimedPayload,
} from './serverTiming'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
@@ -127,8 +132,9 @@ export interface UsageRequestOptions {
skipCache?: boolean
}
type UsageListResponse = {
type UsageListResponse = ServerTimedPayload & {
records?: unknown
server_now_unix_ms?: unknown
pagination?: {
total?: unknown
limit?: unknown
@@ -199,6 +205,7 @@ function normalizeUsageRecordPage(
total: number
page: number
page_size: number
server_timing?: ServerTimedPayload['server_timing']
} {
const records = assertUsageRecords(payload.records)
const pagination = payload.pagination
@@ -220,6 +227,7 @@ function normalizeUsageRecordPage(
total,
page: resolvedPage,
page_size: limit,
...(payload.server_timing ? { server_timing: payload.server_timing } : {}),
}
}
@@ -366,10 +374,12 @@ export const usageApi = {
total: number
page: number
page_size: number
server_timing?: ServerTimedPayload['server_timing']
}> {
const { params, pagination } = buildCurrentUserUsageParams(filters)
const clientSendUnixMs = beginServerTimingSample()
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
return normalizeUsageRecordPage(response.data, pagination)
return normalizeUsageRecordPage(withServerTiming(response.data, clientSendUnixMs), pagination)
},
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
@@ -444,17 +454,21 @@ export const usageApi = {
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
records: UsageRecord[]
stats: UsageStats
server_timing?: ServerTimedPayload['server_timing']
}> {
const statsParams = buildAdminUsageStatsParams(userId, filters)
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
const clientSendUnixMs = beginServerTimingSample()
const [statsResponse, recordsResponse] = await Promise.all([
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
])
const recordsPayload = withServerTiming(recordsResponse.data, clientSendUnixMs)
return {
records: assertUsageRecords(recordsResponse.data.records),
records: assertUsageRecords(recordsPayload.records),
stats: statsResponse.data,
...(recordsPayload.server_timing ? { server_timing: recordsPayload.server_timing } : {}),
}
},
@@ -479,11 +493,13 @@ export const usageApi = {
total: number
limit: number
offset: number
server_timing?: ServerTimedPayload['server_timing']
}> {
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => {
const clientSendUnixMs = beginServerTimingSample()
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
return withServerTiming(response.data, clientSendUnixMs)
})
},
@@ -495,6 +511,7 @@ export const usageApi = {
ids?: string[],
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
): Promise<{
server_timing?: ServerTimedPayload['server_timing']
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
@@ -548,8 +565,9 @@ export const usageApi = {
if (typeof timeRange?.tz_offset_minutes === 'number') {
params.tz_offset_minutes = timeRange.tz_offset_minutes
}
const clientSendUnixMs = beginServerTimingSample()
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
return withServerTiming(response.data, clientSendUnixMs)
},
/**
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { calculateServerClockOffsetMs, useServerClock } from '../useServerClock'
describe('useServerClock', () => {
it('calculates offset from the request midpoint', () => {
const offset = calculateServerClockOffsetMs({
server_now_unix_ms: 10_500,
client_send_unix_ms: 20_000,
client_receive_unix_ms: 20_200,
})
expect(offset).toBe(-9_600)
})
it('ignores missing or invalid timing samples', () => {
expect(calculateServerClockOffsetMs(undefined)).toBeNull()
expect(calculateServerClockOffsetMs({
server_now_unix_ms: Number.NaN,
client_send_unix_ms: 20_000,
client_receive_unix_ms: 20_200,
})).toBeNull()
expect(calculateServerClockOffsetMs({
server_now_unix_ms: 10_500,
client_send_unix_ms: 20_200,
client_receive_unix_ms: 20_000,
})).toBeNull()
})
it('keeps the previous offset when a response has no server timing', () => {
const clock = useServerClock()
clock.updateServerClockOffset({
server_now_unix_ms: 10_500,
client_send_unix_ms: 20_000,
client_receive_unix_ms: 20_200,
})
clock.updateServerClockOffset(undefined)
expect(clock.hasServerClockOffset.value).toBe(true)
expect(clock.serverClockOffsetMs.value).toBe(-9_600)
})
})
@@ -0,0 +1,36 @@
import { ref } from 'vue'
import type { ServerTimingMetadata } from '@/api/serverTiming'
export function calculateServerClockOffsetMs(timing: ServerTimingMetadata | null | undefined): number | null {
if (!timing) return null
const { server_now_unix_ms: serverNowUnixMs, client_send_unix_ms: clientSendUnixMs, client_receive_unix_ms: clientReceiveUnixMs } = timing
if (!Number.isFinite(serverNowUnixMs) || !Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) {
return null
}
if (clientReceiveUnixMs < clientSendUnixMs) {
return null
}
const clientMidpointUnixMs = clientSendUnixMs + ((clientReceiveUnixMs - clientSendUnixMs) / 2)
return serverNowUnixMs - clientMidpointUnixMs
}
export function useServerClock() {
const serverClockOffsetMs = ref(0)
const hasServerClockOffset = ref(false)
function updateServerClockOffset(timing: ServerTimingMetadata | null | undefined): void {
const offsetMs = calculateServerClockOffsetMs(timing)
if (offsetMs == null) return
serverClockOffsetMs.value = offsetMs
hasServerClockOffset.value = true
}
return {
serverClockOffsetMs,
hasServerClockOffset,
updateServerClockOffset,
}
}
@@ -14,6 +14,7 @@ import { createDefaultStats } from '../types'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
import { useServerClock } from './useServerClock'
export interface UseUsageDataOptions {
isAdminPage: Ref<boolean>
@@ -65,6 +66,11 @@ export function useUsageData(options: UseUsageDataOptions) {
// 可用的筛选选项(从统计数据获取,而不是从记录中)
const availableModels = ref<string[]>([])
const availableProviders = ref<string[]>([])
const {
serverClockOffsetMs,
hasServerClockOffset,
updateServerClockOffset,
} = useServerClock()
// 增强的模型统计(包含效率分析)
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
@@ -213,6 +219,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadStatsRequestId) {
return false
}
updateServerClockOffset(userData.server_timing)
stats.value = {
total_requests: userData.total_requests || 0,
@@ -366,6 +373,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadRecordsRequestId) {
return
}
updateServerClockOffset(response.server_timing)
const nextRecords = (response.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0
@@ -375,6 +383,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadRecordsRequestId) {
return
}
updateServerClockOffset(userData.server_timing)
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total || currentRecords.value.length
@@ -557,6 +566,8 @@ export function useUsageData(options: UseUsageDataOptions) {
apiFormatStats,
currentRecords,
totalRecords,
serverClockOffsetMs,
hasServerClockOffset,
// 筛选选项
availableModels,
@@ -568,6 +579,7 @@ export function useUsageData(options: UseUsageDataOptions) {
// 方法
loadStats,
loadRecords,
refreshData
refreshData,
updateServerClockOffset
}
}
+8 -3
View File
@@ -246,7 +246,8 @@ const {
availableModels,
availableProviders,
loadStats,
loadRecords
loadRecords,
updateServerClockOffset
} = useUsageData({ isAdminPage })
// 热力图状态
@@ -445,10 +446,14 @@ const discoveredActiveRequestIds = new Set<string>()
async function loadActiveRequestUpdates(ids?: string[]) {
if (isAdminPage.value) {
return usageApi.getActiveRequests(ids, timeRange.value)
const result = await usageApi.getActiveRequests(ids, timeRange.value)
updateServerClockOffset(result.server_timing)
return result
}
const idsParam = ids?.length ? ids.join(',') : undefined
return meApi.getActiveRequests(idsParam)
const result = await meApi.getActiveRequests(idsParam)
updateServerClockOffset(result.server_timing)
return result
}
async function pollActiveRequests() {