feat(billing): add usage analytics charts and summary totals
Add daily usage trend charts with multi-metric toggles, overview vs user analysis views, date-range filtering, and usage summary API for interval and all-time totals. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,12 +35,17 @@ import type {
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
MemoryV2RuntimeStatusResponse,
|
||||
MemoryV2ConfigRuntimeState,
|
||||
SkillRuntimeAdminConfig,
|
||||
SkillRuntimeAdminConfigResponse,
|
||||
SkillRuntimeCatalogItem,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
UsageStatsBucket,
|
||||
UsageStatsResult,
|
||||
UsageSummaryResult,
|
||||
UsageTotals,
|
||||
WechatAdminSummary,
|
||||
WechatBinding,
|
||||
WechatDeliveryLog,
|
||||
@@ -229,11 +234,15 @@ export async function listAdminUsage(params?: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
}): Promise<PagedResult<UsageRecord>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('userId', params.userId);
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
|
||||
if (params?.startAt) q.set('startAt', String(params.startAt));
|
||||
if (params?.endAt) q.set('endAt', String(params.endAt));
|
||||
const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>(
|
||||
`/admin-api/usage${q.toString() ? `?${q}` : ''}`,
|
||||
);
|
||||
@@ -242,6 +251,146 @@ export async function listAdminUsage(params?: {
|
||||
return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
function toMillis(ts: number) {
|
||||
return ts >= 1_000_000_000_000 ? ts : ts * 1000;
|
||||
}
|
||||
|
||||
function usageDayKey(createdAt: number) {
|
||||
const d = new Date(toMillis(createdAt));
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
async function aggregateUsageStatsFromRecords(params: {
|
||||
userId?: string;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
}): Promise<UsageStatsResult> {
|
||||
const bucketMap = new Map<string, UsageStatsBucket>();
|
||||
let page = 1;
|
||||
const pageSize = 200;
|
||||
const maxPages = 50;
|
||||
|
||||
const startMs = params.startAt * 1000;
|
||||
const endMs = params.endAt * 1000 + 999;
|
||||
|
||||
while (page <= maxPages) {
|
||||
const result = await listAdminUsage({
|
||||
userId: params.userId,
|
||||
page,
|
||||
pageSize,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
});
|
||||
for (const record of result.items) {
|
||||
const createdMs = toMillis(record.createdAt);
|
||||
if (createdMs < startMs || createdMs > endMs) continue;
|
||||
const day = usageDayKey(record.createdAt);
|
||||
const bucket = bucketMap.get(day) ?? {
|
||||
day,
|
||||
count: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costCents: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.inputTokens += record.inputTokens;
|
||||
bucket.outputTokens += record.outputTokens;
|
||||
bucket.costCents += record.costCents;
|
||||
bucketMap.set(day, bucket);
|
||||
}
|
||||
if (page >= result.totalPages || result.items.length === 0) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
buckets: [...bucketMap.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
};
|
||||
}
|
||||
|
||||
function sumUsageBuckets(buckets: UsageStatsBucket[]): UsageTotals {
|
||||
return buckets.reduce(
|
||||
(acc, b) => ({
|
||||
count: acc.count + b.count,
|
||||
inputTokens: acc.inputTokens + b.inputTokens,
|
||||
outputTokens: acc.outputTokens + b.outputTokens,
|
||||
costCents: acc.costCents + b.costCents,
|
||||
}),
|
||||
{ count: 0, inputTokens: 0, outputTokens: 0, costCents: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminUsageSummary(params?: {
|
||||
userId?: string;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
}): Promise<UsageSummaryResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('userId', params.userId);
|
||||
if (params?.startAt) q.set('startAt', String(params.startAt));
|
||||
if (params?.endAt) q.set('endAt', String(params.endAt));
|
||||
const suffix = q.toString() ? `?${q}` : '';
|
||||
try {
|
||||
return await portalFetch<UsageSummaryResult>(`/admin-api/usage/summary${suffix}`);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.status !== 404) throw err;
|
||||
}
|
||||
const allTime = sumUsageBuckets(
|
||||
(await aggregateUsageStatsFromRecords({
|
||||
userId: params?.userId,
|
||||
startAt: 0,
|
||||
endAt: Math.floor(Date.now() / 1000),
|
||||
})).buckets,
|
||||
);
|
||||
if (!params?.startAt || !params?.endAt) {
|
||||
return { range: null, allTime };
|
||||
}
|
||||
const rangeRows = await getAdminUsageStats({
|
||||
userId: params.userId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
});
|
||||
return {
|
||||
range: {
|
||||
...sumUsageBuckets(rangeRows.buckets),
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
},
|
||||
allTime,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminUsageStats(params: {
|
||||
userId?: string;
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
}): Promise<UsageStatsResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.userId) q.set('userId', params.userId);
|
||||
q.set('startAt', String(params.startAt));
|
||||
q.set('endAt', String(params.endAt));
|
||||
|
||||
const attempts = [
|
||||
`/admin-api/usage/stats?${q}`,
|
||||
`/admin-api/usage?${q}&stats=1`,
|
||||
];
|
||||
|
||||
for (const path of attempts) {
|
||||
try {
|
||||
return await portalFetch<UsageStatsResult>(path);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateUsageStatsFromRecords(params);
|
||||
}
|
||||
|
||||
export async function listAdminLedger(params?: {
|
||||
userId?: string;
|
||||
page?: number;
|
||||
@@ -359,6 +508,10 @@ export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusR
|
||||
return portalFetch('/admin-api/memory-v2/status');
|
||||
}
|
||||
|
||||
export async function getMemoryV2ConfigRuntime(): Promise<MemoryV2ConfigRuntimeState> {
|
||||
return portalFetch('/admin-api/memory-v2/runtime');
|
||||
}
|
||||
|
||||
export async function listPersonalMemoryCandidates(
|
||||
status = 'candidate',
|
||||
): Promise<PersonalMemoryCandidateListResponse> {
|
||||
|
||||
Reference in New Issue
Block a user