feat: extend Server-Timing to authenticated user web APIs

Mirror the Admin UI Server-Timing opt-in for user-facing pages so
authenticated callers can inspect total/app/db/redis/deps metrics on
session, profile, keys, usage, payment, and related user APIs.

- Collect when X-User-UI-Request=1 or path is on the user allowlist
- Emit for non-admin only on allowlisted paths (header is not auth)
- Exclude payment public/webhook surfaces
- Mark matching SPA requests and allow the new CORS request header
This commit is contained in:
bestony
2026-07-15 00:23:27 +08:00
parent da85cc7e47
commit 324a491671
9 changed files with 349 additions and 28 deletions
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
ADMIN_UI_REQUEST_HEADER,
USER_UI_REQUEST_HEADER,
isUserTimingAPIPath,
shouldMarkAdminUIRequest,
shouldMarkUserUIRequest,
} from '@/api/adminUIRequest'
describe('Admin UI request marker', () => {
@@ -36,3 +39,62 @@ describe('Admin UI request marker', () => {
expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false)
})
})
describe('User UI request marker', () => {
it('uses the stable request header name', () => {
expect(USER_UI_REQUEST_HEADER).toBe('X-User-UI-Request')
})
it.each([
'/auth/me',
'/auth/revoke-all-sessions',
'/auth/oauth/bind-token',
'/user',
'/user/profile',
'/user/password',
'/user/notify-email/send-code',
'/user/totp/status',
'/user/aff',
'/user/platform-quotas',
'/keys',
'/keys/12',
'/groups/available',
'/groups/rates',
'/channels/available',
'/usage',
'/usage/stats',
'/usage/dashboard/snapshot-v2',
'/announcements',
'/announcements/3/read',
'/redeem',
'/redeem/history',
'/subscriptions',
'/subscriptions/active',
'/channel-monitors',
'/channel-monitors/9/status',
'/payment/config',
'/payment/plans',
'/payment/orders',
'/payment/orders/my',
'/api/v1/auth/me',
'/api/v1/keys?page=1',
'https://api.example.test/api/v1/payment/orders/1',
])('marks user timing API %s', (requestURL) => {
expect(shouldMarkUserUIRequest(requestURL)).toBe(true)
expect(isUserTimingAPIPath(requestURL)).toBe(true)
})
it.each([
'/auth/login',
'/settings/public',
'/admin/users',
'/groups',
'/channels',
'/payment/public/orders/verify',
'/payment/webhook/stripe',
'/api/v1/payment/public/orders/resolve',
'',
])('does not mark non-user timing API %s', (requestURL) => {
expect(shouldMarkUserUIRequest(requestURL)).toBe(false)
})
})
+52
View File
@@ -170,6 +170,58 @@ describe('API Client', () => {
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
})
it('用户侧 timing API 自动带 User UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/auth/me')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-User-UI-Request')).toBe('1')
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
})
it('支付用户 API 带 User UI 标记,公开支付 API 不带', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/payment/plans')
expect(adapter.mock.calls[0][0].headers.get('X-User-UI-Request')).toBe('1')
await apiClient.post('/payment/public/orders/verify', {})
expect(adapter.mock.calls[1][0].headers.get('X-User-UI-Request')).toBeFalsy()
})
it('管理页调用共享 API 时同时带 Admin 与 User UI 标记', async () => {
window.history.replaceState({}, '', '/admin/dashboard')
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/keys')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
expect(config.headers.get('X-User-UI-Request')).toBe('1')
})
})
// --- 响应拦截器 ---
+51
View File
@@ -1,4 +1,5 @@
export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request'
export const USER_UI_REQUEST_HEADER = 'X-User-UI-Request'
function isAdminPath(path: string): boolean {
return (
@@ -20,8 +21,58 @@ function requestPath(rawURL: string): string {
}
}
/** Normalize Axios relative paths and absolute API paths to a comparable form. */
function normalizeAPIPath(path: string): string {
const raw = requestPath(path)
if (!raw) return ''
if (raw === '/api/v1' || raw.startsWith('/api/v1/')) {
return raw.slice('/api/v1'.length) || '/'
}
if (raw.startsWith('/')) {
return raw
}
return `/${raw}`
}
/**
* User-facing web APIs that may emit Server-Timing when ENABLE_SERVER_TIMING is on.
* Mirrors backend isUserTimingPath allowlist (excluding public payment surfaces).
*/
export function isUserTimingAPIPath(requestURL: string): boolean {
const path = normalizeAPIPath(requestURL)
if (!path) return false
if (
path === '/auth/me' ||
path === '/auth/revoke-all-sessions' ||
path === '/auth/oauth/bind-token'
) {
return true
}
if (path === '/user' || path.startsWith('/user/')) return true
if (path === '/keys' || path.startsWith('/keys/')) return true
if (path === '/groups/available' || path === '/groups/rates') return true
if (path === '/channels/available') return true
if (path === '/usage' || path.startsWith('/usage/')) return true
if (path === '/announcements' || path.startsWith('/announcements/')) return true
if (path === '/redeem' || path.startsWith('/redeem/')) return true
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return true
if (path === '/channel-monitors' || path.startsWith('/channel-monitors/')) return true
if (path.startsWith('/payment/')) {
if (path.startsWith('/payment/public') || path.startsWith('/payment/webhook')) {
return false
}
return true
}
return false
}
export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean {
const currentPath =
pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '')
return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath)
}
export function shouldMarkUserUIRequest(requestURL: string): boolean {
return isUserTimingAPIPath(requestURL)
}
+14 -3
View File
@@ -6,7 +6,12 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
import { ADMIN_UI_REQUEST_HEADER, shouldMarkAdminUIRequest } from './adminUIRequest'
import {
ADMIN_UI_REQUEST_HEADER,
USER_UI_REQUEST_HEADER,
shouldMarkAdminUIRequest,
shouldMarkUserUIRequest,
} from './adminUIRequest'
import { getAPIBaseURL } from './url'
export { buildApiUrl, buildGatewayUrl } from './url'
@@ -75,8 +80,14 @@ apiClient.interceptors.request.use(
config.params.timezone = getUserTimezone()
}
if (config.headers && shouldMarkAdminUIRequest(String(config.url || ''))) {
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
if (config.headers) {
const requestURL = String(config.url || '')
if (shouldMarkAdminUIRequest(requestURL)) {
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
}
if (shouldMarkUserUIRequest(requestURL)) {
config.headers[USER_UI_REQUEST_HEADER] = '1'
}
}
return config