373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
/**
|
|
* Usage Analytics — Aggregation functions for the analytics dashboard
|
|
*
|
|
* Processes usage.json history entries into dashboard-ready data:
|
|
* summary cards, daily trends, activity heatmap, model breakdown, etc.
|
|
*/
|
|
|
|
import {
|
|
normalizeModelName as normModel,
|
|
computeCostFromPricing,
|
|
} from "@/lib/usage/costCalculator";
|
|
|
|
/**
|
|
* Compute date range boundaries
|
|
* @param {string} range - "1d" | "7d" | "30d" | "90d" | "ytd" | "all" | "custom"
|
|
* @param {string} [startDate] - ISO string for custom range start
|
|
* @param {string} [endDate] - ISO string for custom range end
|
|
* @returns {{ start: Date, end: Date }}
|
|
*/
|
|
function getDateRange(range: string, startDate?: string, endDate?: string) {
|
|
const end = new Date();
|
|
let start;
|
|
|
|
switch (range) {
|
|
case "custom":
|
|
start = startDate ? new Date(startDate) : new Date(0);
|
|
return {
|
|
start,
|
|
end: endDate ? new Date(endDate) : end,
|
|
};
|
|
case "1d":
|
|
start = new Date(end);
|
|
start.setDate(start.getDate() - 1);
|
|
break;
|
|
case "7d":
|
|
start = new Date(end);
|
|
start.setDate(start.getDate() - 7);
|
|
break;
|
|
case "30d":
|
|
start = new Date(end);
|
|
start.setDate(start.getDate() - 30);
|
|
break;
|
|
case "90d":
|
|
start = new Date(end);
|
|
start.setDate(start.getDate() - 90);
|
|
break;
|
|
case "ytd":
|
|
start = new Date(end.getFullYear(), 0, 1);
|
|
break;
|
|
case "all":
|
|
default:
|
|
start = new Date(0);
|
|
break;
|
|
}
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
/**
|
|
* Format a Date to "YYYY-MM-DD" string
|
|
*/
|
|
function toDateKey(date: Date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
/**
|
|
* Short model name (strip provider prefix paths)
|
|
*/
|
|
function shortModelName(model: string) {
|
|
if (!model) return "unknown";
|
|
// "accounts/fireworks/models/gpt-oss-120b" → "gpt-oss-120b"
|
|
const parts = model.split("/");
|
|
return parts[parts.length - 1] || model;
|
|
}
|
|
|
|
function getApiKeyAnalyticsKey(
|
|
apiKeyId: string | null | undefined,
|
|
apiKeyName: string | null | undefined
|
|
) {
|
|
return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`;
|
|
}
|
|
|
|
/**
|
|
* Compute all analytics data from usage history
|
|
* @param {Array} history - Array of usage entries
|
|
* @param {string} range - Time range filter
|
|
* @param {Object} connectionMap - Map of connectionId → account name
|
|
* @returns {Object} Analytics data
|
|
*/
|
|
export async function computeAnalytics(
|
|
history: any[],
|
|
range = "30d",
|
|
connectionMap: Record<string, string> = {},
|
|
options: { startDate?: string; endDate?: string } = {}
|
|
) {
|
|
const { start, end } = getDateRange(range, options.startDate, options.endDate);
|
|
|
|
// ---- Filtered entries ----
|
|
const entries = history.filter((e) => {
|
|
const t = new Date(e.timestamp);
|
|
return t >= start && t <= end;
|
|
});
|
|
|
|
// ---- Summary ----
|
|
const summary = {
|
|
totalTokens: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
totalCost: 0,
|
|
totalRequests: entries.length,
|
|
uniqueModels: new Set<string>(),
|
|
uniqueAccounts: new Set<string>(),
|
|
uniqueApiKeys: new Set<string>(),
|
|
};
|
|
|
|
// ---- Daily trend ----
|
|
const dailyMap: Record<string, any> = {}; // "YYYY-MM-DD" → { requests, promptTokens, completionTokens, cost }
|
|
const dailyByModelMap: Record<string, Record<string, number>> = {}; // "YYYY-MM-DD" → { modelShort → tokens }
|
|
|
|
// ---- Activity heatmap (always last 365 days, regardless of range filter) ----
|
|
const heatmapStart = new Date();
|
|
heatmapStart.setDate(heatmapStart.getDate() - 364);
|
|
const activityMap: Record<string, number> = {};
|
|
|
|
// ---- By model / account / provider ----
|
|
const byModelMap: Record<string, any> = {};
|
|
const byAccountMap: Record<string, any> = {};
|
|
const byProviderMap: Record<string, any> = {};
|
|
const byApiKeyMap: Record<string, any> = {};
|
|
|
|
// ---- Weekly pattern (0=Sun..6=Sat) ----
|
|
const weeklyTokens = [0, 0, 0, 0, 0, 0, 0];
|
|
const weeklyCounts = [0, 0, 0, 0, 0, 0, 0];
|
|
|
|
// ---- Single pass over ALL history for heatmap ----
|
|
for (const entry of history) {
|
|
const entryDate = new Date(entry.timestamp);
|
|
if (entryDate >= heatmapStart) {
|
|
const key = toDateKey(entryDate);
|
|
const tokens =
|
|
(entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0) +
|
|
(entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0);
|
|
activityMap[key] = (activityMap[key] || 0) + tokens;
|
|
}
|
|
}
|
|
|
|
// Pre-fetch pricing for all unique (provider, model) pairs — one DB round-trip
|
|
// per unique pair instead of one per entry, then compute costs synchronously.
|
|
const { getPricingForModel } = await import("@/lib/localDb");
|
|
const pricingCache = new Map<string, Record<string, unknown> | null>();
|
|
const uniquePairs = new Set(entries.map((e) => `${e.provider}|||${e.model}`));
|
|
await Promise.all(
|
|
[...uniquePairs].map(async (key) => {
|
|
const [provider, model] = key.split("|||");
|
|
let pricing = (await getPricingForModel(provider, model)) as Record<string, unknown> | null;
|
|
if (!pricing) {
|
|
const normalized = normModel(model);
|
|
if (normalized !== model) {
|
|
pricing = (await getPricingForModel(provider, normalized)) as Record<
|
|
string,
|
|
unknown
|
|
> | null;
|
|
}
|
|
}
|
|
pricingCache.set(key, pricing ?? null);
|
|
})
|
|
);
|
|
|
|
// ---- Single pass over filtered entries for everything else ----
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
const pt = entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0;
|
|
const ct = entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0;
|
|
const totalTkns = pt + ct;
|
|
const entryDate = new Date(entry.timestamp);
|
|
const dateKey = toDateKey(entryDate);
|
|
const dayOfWeek = entryDate.getDay();
|
|
const modelShort = shortModelName(entry.model);
|
|
|
|
const pricingKey = `${entry.provider}|||${entry.model}`;
|
|
const cost = computeCostFromPricing(pricingCache.get(pricingKey), entry.tokens, {
|
|
provider: entry.provider,
|
|
model: entry.model,
|
|
flatRateAsZero: true,
|
|
});
|
|
|
|
// Summary
|
|
summary.promptTokens += pt;
|
|
summary.completionTokens += ct;
|
|
summary.totalTokens += totalTkns;
|
|
summary.totalCost += cost;
|
|
if (entry.model) summary.uniqueModels.add(modelShort);
|
|
if (entry.connectionId) summary.uniqueAccounts.add(entry.connectionId);
|
|
if (entry.apiKeyId || entry.apiKeyName) {
|
|
summary.uniqueApiKeys.add(getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName));
|
|
}
|
|
|
|
// Daily trend
|
|
if (!dailyMap[dateKey]) {
|
|
dailyMap[dateKey] = {
|
|
date: dateKey,
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
cost: 0,
|
|
};
|
|
}
|
|
dailyMap[dateKey].requests++;
|
|
dailyMap[dateKey].promptTokens += pt;
|
|
dailyMap[dateKey].completionTokens += ct;
|
|
dailyMap[dateKey].cost += cost;
|
|
|
|
// Daily by model
|
|
if (!dailyByModelMap[dateKey]) dailyByModelMap[dateKey] = {};
|
|
dailyByModelMap[dateKey][modelShort] = (dailyByModelMap[dateKey][modelShort] || 0) + totalTkns;
|
|
|
|
// Weekly pattern
|
|
weeklyTokens[dayOfWeek] += totalTkns;
|
|
weeklyCounts[dayOfWeek]++;
|
|
|
|
// By model
|
|
if (!byModelMap[modelShort]) {
|
|
byModelMap[modelShort] = {
|
|
model: modelShort,
|
|
provider: entry.provider,
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
totalTokens: 0,
|
|
cost: 0,
|
|
};
|
|
}
|
|
byModelMap[modelShort].requests++;
|
|
byModelMap[modelShort].promptTokens += pt;
|
|
byModelMap[modelShort].completionTokens += ct;
|
|
byModelMap[modelShort].totalTokens += totalTkns;
|
|
byModelMap[modelShort].cost += cost;
|
|
|
|
// By account
|
|
const accountName = entry.connectionId
|
|
? connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}`
|
|
: entry.provider || "unknown";
|
|
if (!byAccountMap[accountName]) {
|
|
byAccountMap[accountName] = { account: accountName, totalTokens: 0, requests: 0, cost: 0 };
|
|
}
|
|
byAccountMap[accountName].totalTokens += totalTkns;
|
|
byAccountMap[accountName].requests++;
|
|
byAccountMap[accountName].cost += cost;
|
|
|
|
// By provider
|
|
const prov = entry.provider || "unknown";
|
|
if (!byProviderMap[prov]) {
|
|
byProviderMap[prov] = {
|
|
provider: prov,
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
totalTokens: 0,
|
|
cost: 0,
|
|
};
|
|
}
|
|
byProviderMap[prov].requests++;
|
|
byProviderMap[prov].promptTokens += pt;
|
|
byProviderMap[prov].completionTokens += ct;
|
|
byProviderMap[prov].totalTokens += totalTkns;
|
|
byProviderMap[prov].cost += cost;
|
|
|
|
// By API key
|
|
if (entry.apiKeyId || entry.apiKeyName) {
|
|
const keyName = entry.apiKeyName || entry.apiKeyId || "unknown";
|
|
const key = getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName);
|
|
const keyLabel = entry.apiKeyId ? `${keyName} (${entry.apiKeyId})` : keyName;
|
|
if (!byApiKeyMap[key]) {
|
|
byApiKeyMap[key] = {
|
|
apiKey: keyLabel,
|
|
apiKeyId: entry.apiKeyId || null,
|
|
apiKeyName: keyName,
|
|
historicalApiKeyNames: [],
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
totalTokens: 0,
|
|
cost: 0,
|
|
};
|
|
}
|
|
if (entry.apiKeyName && !byApiKeyMap[key].historicalApiKeyNames.includes(entry.apiKeyName)) {
|
|
byApiKeyMap[key].historicalApiKeyNames.push(entry.apiKeyName);
|
|
}
|
|
byApiKeyMap[key].requests++;
|
|
byApiKeyMap[key].promptTokens += pt;
|
|
byApiKeyMap[key].completionTokens += ct;
|
|
byApiKeyMap[key].totalTokens += totalTkns;
|
|
byApiKeyMap[key].cost += cost;
|
|
}
|
|
}
|
|
|
|
// ---- Build sorted arrays ----
|
|
const dailyTrend = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
// Daily by model — collect all unique model names
|
|
const allModels = new Set<string>();
|
|
for (const day of Object.values(dailyByModelMap)) {
|
|
for (const m of Object.keys(day)) allModels.add(m);
|
|
}
|
|
const dailyByModel = dailyTrend.map((d) => {
|
|
const row = { date: d.date };
|
|
for (const m of allModels) {
|
|
row[m] = dailyByModelMap[d.date]?.[m] || 0;
|
|
}
|
|
return row;
|
|
});
|
|
|
|
const byModel = Object.values(byModelMap)
|
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
.map((m) => ({
|
|
...m,
|
|
pct: summary.totalTokens > 0 ? ((m.totalTokens / summary.totalTokens) * 100).toFixed(1) : "0",
|
|
}));
|
|
|
|
const byAccount = Object.values(byAccountMap).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
const byProvider = Object.values(byProviderMap).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
const byApiKey = Object.values(byApiKeyMap).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
|
|
// Weekly pattern (avg tokens per day of week)
|
|
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
const weeklyPattern = weekDays.map((name, i) => ({
|
|
day: name,
|
|
avgTokens: weeklyCounts[i] > 0 ? Math.round(weeklyTokens[i] / weeklyCounts[i]) : 0,
|
|
totalTokens: weeklyTokens[i],
|
|
}));
|
|
|
|
// Streak — consecutive days with activity (from today going back)
|
|
let streak = 0;
|
|
const today = new Date();
|
|
for (let i = 0; i < 365; i++) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const key = toDateKey(d);
|
|
if (activityMap[key] && activityMap[key] > 0) {
|
|
streak++;
|
|
} else if (i > 0) {
|
|
break; // Stop at first gap (skip today if no activity yet)
|
|
}
|
|
}
|
|
|
|
return {
|
|
summary: {
|
|
totalTokens: summary.totalTokens,
|
|
promptTokens: summary.promptTokens,
|
|
completionTokens: summary.completionTokens,
|
|
totalCost: summary.totalCost,
|
|
totalRequests: summary.totalRequests,
|
|
uniqueModels: summary.uniqueModels.size,
|
|
uniqueAccounts: summary.uniqueAccounts.size,
|
|
uniqueApiKeys: summary.uniqueApiKeys.size,
|
|
streak,
|
|
},
|
|
dailyTrend,
|
|
dailyByModel,
|
|
modelNames: [...allModels],
|
|
byModel,
|
|
byAccount,
|
|
byProvider,
|
|
byApiKey,
|
|
activityMap,
|
|
weeklyPattern,
|
|
range,
|
|
};
|
|
}
|