mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
feat: filter analytics by profile
This commit is contained in:
1 parent
14cc787f18
commit
f1d655e425
11 files changed
+416
-47
No files matched your search
@@ -40,6 +40,7 @@ import {
|
||||
getModelsUsed,
|
||||
getProviderModelKey,
|
||||
} from './model-identity';
|
||||
import { annotateUsageProfile, filterByProfile } from './profile-filter';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { listAccountInstancePaths } from '../../management/instance-directory';
|
||||
|
||||
@@ -146,12 +147,16 @@ function finalizeHourlyUsage(hour: HourlyUsage): HourlyUsage {
|
||||
* Merge daily usage data from multiple sources
|
||||
* Combines entries with same date by aggregating tokens
|
||||
*/
|
||||
export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
||||
export function mergeDailyData(
|
||||
sources: DailyUsage[][],
|
||||
options: { preserveProfile?: boolean } = {}
|
||||
): DailyUsage[] {
|
||||
const dateMap = new Map<string, DailyUsage>();
|
||||
|
||||
for (const source of sources) {
|
||||
for (const day of source) {
|
||||
const existing = dateMap.get(day.date);
|
||||
const mergeKey = options.preserveProfile ? `${day.profile ?? ''}\u0000${day.date}` : day.date;
|
||||
const existing = dateMap.get(mergeKey);
|
||||
if (existing) {
|
||||
// Aggregate tokens for same date
|
||||
existing.inputTokens += day.inputTokens;
|
||||
@@ -178,8 +183,9 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
||||
} else {
|
||||
// Clone to avoid mutating original
|
||||
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
||||
dateMap.set(day.date, {
|
||||
dateMap.set(mergeKey, {
|
||||
...day,
|
||||
...(options.preserveProfile && day.profile ? { profile: day.profile } : {}),
|
||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||
modelBreakdowns,
|
||||
});
|
||||
@@ -195,12 +201,18 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
||||
/**
|
||||
* Merge monthly usage data from multiple sources
|
||||
*/
|
||||
export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
||||
export function mergeMonthlyData(
|
||||
sources: MonthlyUsage[][],
|
||||
options: { preserveProfile?: boolean } = {}
|
||||
): MonthlyUsage[] {
|
||||
const monthMap = new Map<string, MonthlyUsage>();
|
||||
|
||||
for (const source of sources) {
|
||||
for (const month of source) {
|
||||
const existing = monthMap.get(month.month);
|
||||
const mergeKey = options.preserveProfile
|
||||
? `${month.profile ?? ''}\u0000${month.month}`
|
||||
: month.month;
|
||||
const existing = monthMap.get(mergeKey);
|
||||
if (existing) {
|
||||
existing.inputTokens += month.inputTokens;
|
||||
existing.outputTokens += month.outputTokens;
|
||||
@@ -224,8 +236,9 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
||||
}
|
||||
} else {
|
||||
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
||||
monthMap.set(month.month, {
|
||||
monthMap.set(mergeKey, {
|
||||
...month,
|
||||
...(options.preserveProfile && month.profile ? { profile: month.profile } : {}),
|
||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||
modelBreakdowns,
|
||||
});
|
||||
@@ -242,12 +255,18 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
||||
* Merge hourly usage data from multiple sources
|
||||
* Combines entries with same hour by aggregating tokens
|
||||
*/
|
||||
export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
||||
export function mergeHourlyData(
|
||||
sources: HourlyUsage[][],
|
||||
options: { preserveProfile?: boolean } = {}
|
||||
): HourlyUsage[] {
|
||||
const hourMap = new Map<string, HourlyUsage>();
|
||||
|
||||
for (const source of sources) {
|
||||
for (const hour of source) {
|
||||
const existing = hourMap.get(hour.hour);
|
||||
const mergeKey = options.preserveProfile
|
||||
? `${hour.profile ?? ''}\u0000${hour.hour}`
|
||||
: hour.hour;
|
||||
const existing = hourMap.get(mergeKey);
|
||||
if (existing) {
|
||||
existing.inputTokens += hour.inputTokens;
|
||||
existing.outputTokens += hour.outputTokens;
|
||||
@@ -273,8 +292,9 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
||||
}
|
||||
} else {
|
||||
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
||||
hourMap.set(hour.hour, {
|
||||
hourMap.set(mergeKey, {
|
||||
...hour,
|
||||
...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}),
|
||||
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||
modelBreakdowns,
|
||||
requestCount: getHourlyRequestCount(hour),
|
||||
@@ -393,7 +413,10 @@ async function refreshFromSource(): Promise<{
|
||||
await syncCliproxyUsage();
|
||||
|
||||
// Load canonical default data and avoid counting the active instance twice
|
||||
const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() });
|
||||
const defaultData = annotateUsageProfile(
|
||||
await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() }),
|
||||
'default'
|
||||
);
|
||||
|
||||
// Load data from all CCS instances sequentially
|
||||
const instancePaths = getInstancePaths();
|
||||
@@ -406,7 +429,10 @@ async function refreshFromSource(): Promise<{
|
||||
|
||||
for (const instancePath of instancePaths) {
|
||||
try {
|
||||
const data = await loadInstanceData(instancePath);
|
||||
const data = annotateUsageProfile(
|
||||
await loadInstanceData(instancePath),
|
||||
path.basename(instancePath)
|
||||
);
|
||||
instanceDataResults.push(data);
|
||||
} catch (err) {
|
||||
const instanceName = path.basename(instancePath);
|
||||
@@ -471,9 +497,9 @@ async function refreshFromSource(): Promise<{
|
||||
}
|
||||
|
||||
// Merge all data sources
|
||||
const daily = mergeDailyData(allDailySources);
|
||||
const hourly = mergeHourlyData(allHourlySources);
|
||||
const monthly = mergeMonthlyData(allMonthlySources);
|
||||
const daily = mergeDailyData(allDailySources, { preserveProfile: true });
|
||||
const hourly = mergeHourlyData(allHourlySources, { preserveProfile: true });
|
||||
const monthly = mergeMonthlyData(allMonthlySources, { preserveProfile: true });
|
||||
const session = mergeSessionData(allSessionSources);
|
||||
|
||||
// Update in-memory cache
|
||||
@@ -602,31 +628,35 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
|
||||
}
|
||||
|
||||
/** Cached loader for daily usage data */
|
||||
export async function getCachedDailyData(): Promise<DailyUsage[]> {
|
||||
return getCachedData('daily', CACHE_TTL.daily, async () => {
|
||||
export async function getCachedDailyData(profile?: string): Promise<DailyUsage[]> {
|
||||
const data = await getCachedData('daily', CACHE_TTL.daily, async () => {
|
||||
return (await refreshFromSourceCoalesced()).daily;
|
||||
});
|
||||
return mergeDailyData([filterByProfile(data, profile)]);
|
||||
}
|
||||
|
||||
/** Cached loader for monthly usage data */
|
||||
export async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
|
||||
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
||||
export async function getCachedMonthlyData(profile?: string): Promise<MonthlyUsage[]> {
|
||||
const data = await getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
||||
return (await refreshFromSourceCoalesced()).monthly;
|
||||
});
|
||||
return mergeMonthlyData([filterByProfile(data, profile)]);
|
||||
}
|
||||
|
||||
/** Cached loader for session data */
|
||||
export async function getCachedSessionData(): Promise<SessionUsage[]> {
|
||||
return getCachedData('session', CACHE_TTL.session, async () => {
|
||||
export async function getCachedSessionData(profile?: string): Promise<SessionUsage[]> {
|
||||
const data = await getCachedData('session', CACHE_TTL.session, async () => {
|
||||
return (await refreshFromSourceCoalesced()).session;
|
||||
});
|
||||
return filterByProfile(data, profile);
|
||||
}
|
||||
|
||||
/** Cached loader for hourly usage data */
|
||||
export async function getCachedHourlyData(): Promise<HourlyUsage[]> {
|
||||
return getCachedData('hourly', CACHE_TTL.daily, async () => {
|
||||
export async function getCachedHourlyData(profile?: string): Promise<HourlyUsage[]> {
|
||||
const data = await getCachedData('hourly', CACHE_TTL.daily, async () => {
|
||||
return (await refreshFromSourceCoalesced()).hourly;
|
||||
});
|
||||
return mergeHourlyData([filterByProfile(data, profile)]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getModelsUsed,
|
||||
getProviderModelKey,
|
||||
} from './model-identity';
|
||||
import { normalizeProfileQuery } from './profile-filter';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
export interface UsageQuery {
|
||||
since?: string; // YYYYMMDD format
|
||||
until?: string; // YYYYMMDD format
|
||||
profile?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
@@ -407,8 +409,9 @@ export async function handleSummary(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const dailyData = await getCachedDailyData(profile);
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
let totalInputTokens = 0,
|
||||
@@ -466,8 +469,9 @@ export async function handleDaily(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const dailyData = await getCachedDailyData(profile);
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
const trends = filtered.map((day) => ({
|
||||
@@ -498,8 +502,9 @@ export async function handleHourly(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const hourlyData = await getCachedHourlyData();
|
||||
const hourlyData = await getCachedHourlyData(profile);
|
||||
|
||||
const filtered = (hourlyData || []).filter((h) => {
|
||||
const hourDate = h.hour.slice(0, 10).replace(/-/g, '');
|
||||
@@ -538,8 +543,9 @@ export async function handleModels(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const dailyData = await getCachedDailyData(profile);
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
const modelMap = new Map<
|
||||
@@ -644,11 +650,12 @@ export async function handleSessions(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const limit = validateLimit(req.query.limit);
|
||||
const offset = validateOffset(req.query.offset);
|
||||
|
||||
const sessionData = await getCachedSessionData();
|
||||
const sessionData = await getCachedSessionData(profile);
|
||||
const filtered = filterByDateRange(sessionData, since, until);
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
||||
@@ -694,6 +701,7 @@ export async function handleMonthly(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
let filtered: Array<{
|
||||
month: string;
|
||||
@@ -707,7 +715,7 @@ export async function handleMonthly(
|
||||
}>;
|
||||
|
||||
if (since || until) {
|
||||
const dailyData = filterByDateRange(await getCachedDailyData(), since, until);
|
||||
const dailyData = filterByDateRange(await getCachedDailyData(profile), since, until);
|
||||
const monthMap = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -789,7 +797,7 @@ export async function handleMonthly(
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
} else {
|
||||
filtered = await getCachedMonthlyData();
|
||||
filtered = await getCachedMonthlyData(profile);
|
||||
}
|
||||
|
||||
const result = filtered.map((m) => ({
|
||||
@@ -836,8 +844,9 @@ export async function handleInsights(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const profile = normalizeProfileQuery(req.query.profile);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const dailyData = await getCachedDailyData(profile);
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
const anomalies = detectAnomalies(filtered);
|
||||
const summary = summarizeAnomalies(anomalies);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types';
|
||||
|
||||
export interface ProfileScopedUsageData {
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}
|
||||
|
||||
const PROFILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
export function normalizeProfileQuery(profile?: string): string | undefined {
|
||||
const value = profile?.trim();
|
||||
if (!value || value === 'all') return undefined;
|
||||
if (!PROFILE_NAME_REGEX.test(value)) {
|
||||
throw new Error('Invalid profile filter');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function annotateUsageProfile(
|
||||
data: ProfileScopedUsageData,
|
||||
profile: string
|
||||
): ProfileScopedUsageData {
|
||||
return {
|
||||
daily: data.daily.map((item) => ({ ...item, profile })),
|
||||
hourly: data.hourly.map((item) => ({ ...item, profile })),
|
||||
monthly: data.monthly.map((item) => ({ ...item, profile })),
|
||||
session: data.session.map((item) => ({ ...item, profile })),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterByProfile<T extends { profile?: string }>(data: T[], profile?: string): T[] {
|
||||
if (!profile) return data;
|
||||
return data.filter((item) => item.profile === profile);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface ModelBreakdown {
|
||||
/** Daily usage aggregation (YYYY-MM-DD) */
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -41,6 +43,8 @@ export interface DailyUsage {
|
||||
/** Hourly usage aggregation (YYYY-MM-DD HH:00) */
|
||||
export interface HourlyUsage {
|
||||
hour: string; // Format: "YYYY-MM-DD HH:00"
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -60,6 +64,8 @@ export interface HourlyUsage {
|
||||
/** Monthly usage aggregation (YYYY-MM) */
|
||||
export interface MonthlyUsage {
|
||||
month: string;
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -73,6 +79,8 @@ export interface MonthlyUsage {
|
||||
/** Session-level usage aggregation */
|
||||
export interface SessionUsage {
|
||||
sessionId: string;
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
projectPath: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
|
||||
Reference in new issue
Block a user