fix: analytics overload problem with group and indexing
This commit is contained in:
parent
cb55e2d24a
commit
e8276cde3f
6 changed files with 339 additions and 181 deletions
|
|
@ -28,5 +28,8 @@ CREATE TABLE IF NOT EXISTS backend_metrics (
|
||||||
-- Indexes for performance
|
-- Indexes for performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
|
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
|
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
|
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_date ON backend_metrics(date);
|
CREATE INDEX IF NOT EXISTS idx_backend_metrics_date ON backend_metrics(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ CREATE TABLE IF NOT EXISTS request_logs (
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date ON request_logs(local_date);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date ON request_logs(local_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_logs_detail_logged ON request_logs(detail_logged);
|
CREATE INDEX IF NOT EXISTS idx_request_logs_detail_logged ON request_logs(detail_logged);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,25 @@ import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { ensureDir, getAnalyticsDbPath } from './db-paths';
|
import { ensureDir, getAnalyticsDbPath } from './db-paths';
|
||||||
|
|
||||||
|
function hasIndex(database: Database.Database, tableName: string, indexName: string): boolean {
|
||||||
|
const result = database.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?`).get(tableName, indexName) as { name: string } | undefined;
|
||||||
|
return Boolean(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAnalyticsIndexes(db: Database.Database): void {
|
||||||
|
const indexes: Array<{ name: string; table: string; sql: string }> = [
|
||||||
|
{ name: 'idx_usage_stats_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date)' },
|
||||||
|
{ name: 'idx_usage_stats_user_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date)' },
|
||||||
|
{ name: 'idx_backend_metrics_backend_date', table: 'backend_metrics', sql: 'CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, table, sql } of indexes) {
|
||||||
|
if (!hasIndex(db, table, name)) {
|
||||||
|
db.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let db: Database.Database;
|
let db: Database.Database;
|
||||||
|
|
||||||
export function getAnalyticsDb(): Database.Database {
|
export function getAnalyticsDb(): Database.Database {
|
||||||
|
|
@ -16,6 +35,7 @@ export function getAnalyticsDb(): Database.Database {
|
||||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
|
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
ensureAnalyticsIndexes(db);
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,22 @@ function initRequestLogsSchema(db: Database.Database): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureRequestLogsIndexes(db: Database.Database): void {
|
||||||
|
const existingIndexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'request_logs'").all() as Array<{ name: string }>;
|
||||||
|
const indexNames = new Set(existingIndexes.map((idx) => idx.name));
|
||||||
|
|
||||||
|
const indexes = [
|
||||||
|
['idx_request_logs_local_date_backend', 'CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id)'],
|
||||||
|
['idx_request_logs_completion_tokens', 'CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, sql] of indexes) {
|
||||||
|
if (!indexNames.has(name)) {
|
||||||
|
db.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
||||||
const existing = connections.get(monthKey);
|
const existing = connections.get(monthKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -31,6 +47,7 @@ export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Databas
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
initRequestLogsSchema(db);
|
initRequestLogsSchema(db);
|
||||||
|
ensureRequestLogsIndexes(db);
|
||||||
connections.set(monthKey, db);
|
connections.set(monthKey, db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,85 +4,117 @@ import { AnalyticsService } from '../services/AnalyticsService';
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
router.get('/usage', (req: Request, res: Response) => {
|
router.get('/usage', (req: Request, res: Response) => {
|
||||||
const { userId, backendId, days } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getUsageStats(
|
const { userId, backendId, days } = req.query;
|
||||||
userId ? Number(userId) : undefined,
|
const result = AnalyticsService.getUsageStats(
|
||||||
backendId ? Number(backendId) : undefined,
|
userId ? Number(userId) : undefined,
|
||||||
days ? Number(days) : 30
|
backendId ? Number(backendId) : undefined,
|
||||||
);
|
days ? Number(days) : 30
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch usage stats', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/requests', (req: Request, res: Response) => {
|
router.get('/requests', (req: Request, res: Response) => {
|
||||||
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getRequestLogs({
|
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
|
||||||
month: typeof month === 'string' ? month : undefined,
|
const result = AnalyticsService.getRequestLogs({
|
||||||
date: typeof date === 'string' ? date : undefined,
|
month: typeof month === 'string' ? month : undefined,
|
||||||
limit: limit ? Number(limit) : 100,
|
date: typeof date === 'string' ? date : undefined,
|
||||||
offset: offset ? Number(offset) : 0,
|
limit: limit ? Number(limit) : 100,
|
||||||
q: typeof q === 'string' ? q : undefined,
|
offset: offset ? Number(offset) : 0,
|
||||||
userId: userId ? Number(userId) : undefined,
|
q: typeof q === 'string' ? q : undefined,
|
||||||
backendId: backendId ? Number(backendId) : undefined,
|
userId: userId ? Number(userId) : undefined,
|
||||||
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
|
backendId: backendId ? Number(backendId) : undefined,
|
||||||
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
|
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
|
||||||
});
|
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
|
||||||
res.json(result);
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch request logs', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/metrics', (req: Request, res: Response) => {
|
router.get('/metrics', (req: Request, res: Response) => {
|
||||||
const { backendId, days } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getBackendMetrics(
|
const { backendId, days } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getBackendMetrics(
|
||||||
days ? Number(days) : 30
|
backendId ? Number(backendId) : undefined,
|
||||||
);
|
days ? Number(days) : 30
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch backend metrics', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/daily-totals', (req: Request, res: Response) => {
|
router.get('/daily-totals', (req: Request, res: Response) => {
|
||||||
const { backendId, days } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getDailyTotals(
|
const { backendId, days } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getDailyTotals(
|
||||||
days ? Number(days) : 30
|
backendId ? Number(backendId) : undefined,
|
||||||
);
|
days ? Number(days) : 30
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch daily totals', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/backend-quality', (req: Request, res: Response) => {
|
router.get('/backend-quality', (req: Request, res: Response) => {
|
||||||
const { backendId, days } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getBackendQuality(
|
const { backendId, days } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getBackendQuality(
|
||||||
days ? Number(days) : 30
|
backendId ? Number(backendId) : undefined,
|
||||||
);
|
days ? Number(days) : 30
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch backend quality', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/model-trends', (req: Request, res: Response) => {
|
router.get('/model-trends', (req: Request, res: Response) => {
|
||||||
const { backendId, days, limit } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getModelTrends(
|
const { backendId, days, limit } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getModelTrends(
|
||||||
days ? Number(days) : 30,
|
backendId ? Number(backendId) : undefined,
|
||||||
limit ? Number(limit) : 8
|
days ? Number(days) : 30,
|
||||||
);
|
limit ? Number(limit) : 8
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch model trends', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/response-length-histogram', (req: Request, res: Response) => {
|
router.get('/response-length-histogram', (req: Request, res: Response) => {
|
||||||
const { backendId, days, bins } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getResponseLengthHistogram(
|
const { backendId, days, bins } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getResponseLengthHistogram(
|
||||||
days ? Number(days) : 30,
|
backendId ? Number(backendId) : undefined,
|
||||||
bins ? Number(bins) : 20
|
days ? Number(days) : 30,
|
||||||
);
|
bins ? Number(bins) : 20
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch response length histogram', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/response-length-box-plot', (req: Request, res: Response) => {
|
router.get('/response-length-box-plot', (req: Request, res: Response) => {
|
||||||
const { backendId, days } = req.query;
|
try {
|
||||||
const result = AnalyticsService.getResponseLengthBoxPlot(
|
const { backendId, days } = req.query;
|
||||||
backendId ? Number(backendId) : undefined,
|
const result = AnalyticsService.getResponseLengthBoxPlot(
|
||||||
days ? Number(days) : 30
|
backendId ? Number(backendId) : undefined,
|
||||||
);
|
days ? Number(days) : 30
|
||||||
res.json(result);
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch response length box plot', details: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,6 @@ type DailyTotalsRow = {
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RequestLogRangeRow = {
|
|
||||||
local_date: string;
|
|
||||||
backend_id: number;
|
|
||||||
request_model: string | null;
|
|
||||||
routed_model: string | null;
|
|
||||||
response_model: string | null;
|
|
||||||
completion_tokens: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDateRange(days: number): { startDate: string; endDate: string } {
|
function getDateRange(days: number): { startDate: string; endDate: string } {
|
||||||
const normalizedDays = Math.max(1, days);
|
const normalizedDays = Math.max(1, days);
|
||||||
const endDate = getLocalDateKey();
|
const endDate = getLocalDateKey();
|
||||||
|
|
@ -37,17 +28,17 @@ function getDateRange(days: number): { startDate: string; endDate: string } {
|
||||||
return { startDate, endDate };
|
return { startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRequestLogRangeWhere(filter: RequestLogFilter): { whereClause: string; params: unknown[] } {
|
function buildWhereClause(startDate: string, endDate: string, backendId: number | undefined): { whereClause: string; params: unknown[] } {
|
||||||
const clauses = ['local_date >= ?', 'local_date <= ?'];
|
const clauses = ['local_date >= ?', 'local_date <= ?'];
|
||||||
const params: unknown[] = [filter.startDate, filter.endDate];
|
const params: unknown[] = [startDate, endDate];
|
||||||
|
|
||||||
if (filter.backendId) {
|
if (backendId) {
|
||||||
clauses.push('backend_id = ?');
|
clauses.push('backend_id = ?');
|
||||||
params.push(filter.backendId);
|
params.push(backendId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whereClause: `WHERE ${clauses.join(' AND ')}`,
|
whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||||
params,
|
params,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -63,10 +54,10 @@ function groupByDate(rows: DailyTotalsRow[]): DailyTotalsRow[] {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const existing = grouped.get(row.date);
|
const existing = grouped.get(row.date);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.total_requests += row.total_requests;
|
existing.total_requests += row.total_requests;
|
||||||
existing.total_tokens += row.total_tokens;
|
existing.total_tokens += row.total_tokens;
|
||||||
} else {
|
} else {
|
||||||
grouped.set(row.date, { ...row });
|
grouped.set(row.date, { ...row });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,49 +122,22 @@ export class AnalyticsService {
|
||||||
const db = getAnalyticsDb();
|
const db = getAnalyticsDb();
|
||||||
const today = getLocalDateKey();
|
const today = getLocalDateKey();
|
||||||
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
|
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
|
||||||
|
const tokens = logData.total_tokens || 0;
|
||||||
|
const responseTime = logData.response_time_ms || 0;
|
||||||
|
const errorIncrement = isSuccess ? 0 : 1;
|
||||||
|
const initialSuccessRate = isSuccess ? 1.0 : 0.0;
|
||||||
|
|
||||||
const existing = db.prepare(
|
db.prepare(`
|
||||||
'SELECT * FROM backend_metrics WHERE backend_id = ? AND date = ?'
|
INSERT INTO backend_metrics (backend_id, date, total_requests, total_tokens, avg_response_time_ms, error_count, success_rate)
|
||||||
).get(backendId, today) as {
|
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||||||
total_requests: number;
|
ON CONFLICT(backend_id, date)
|
||||||
total_tokens: number;
|
DO UPDATE SET
|
||||||
avg_response_time_ms: number;
|
total_requests = total_requests + 1,
|
||||||
error_count: number;
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
} | undefined;
|
avg_response_time_ms = (avg_response_time_ms * total_requests + excluded.avg_response_time_ms) / (total_requests + 1),
|
||||||
|
error_count = error_count + excluded.error_count,
|
||||||
if (existing) {
|
success_rate = (total_requests + 1 - (error_count + excluded.error_count)) / (total_requests + 1)
|
||||||
const newTotalRequests = existing.total_requests + 1;
|
`).run(backendId, today, tokens, responseTime, errorIncrement, initialSuccessRate);
|
||||||
const newTotalTokens = existing.total_tokens + (logData.total_tokens || 0);
|
|
||||||
const newErrorCount = existing.error_count + (isSuccess ? 0 : 1);
|
|
||||||
const newAvgResponseTime = logData.response_time_ms
|
|
||||||
? (existing.avg_response_time_ms * existing.total_requests + logData.response_time_ms) / newTotalRequests
|
|
||||||
: existing.avg_response_time_ms;
|
|
||||||
const newSuccessRate = (newTotalRequests - newErrorCount) / newTotalRequests;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE backend_metrics SET
|
|
||||||
total_requests = ?,
|
|
||||||
total_tokens = ?,
|
|
||||||
avg_response_time_ms = ?,
|
|
||||||
error_count = ?,
|
|
||||||
success_rate = ?
|
|
||||||
WHERE backend_id = ? AND date = ?
|
|
||||||
`).run(newTotalRequests, newTotalTokens, newAvgResponseTime, newErrorCount, newSuccessRate, backendId, today);
|
|
||||||
} else {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO backend_metrics (
|
|
||||||
backend_id, date, total_requests, total_tokens,
|
|
||||||
avg_response_time_ms, error_count, success_rate
|
|
||||||
) VALUES (?, ?, 1, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
backendId,
|
|
||||||
today,
|
|
||||||
logData.total_tokens || 0,
|
|
||||||
logData.response_time_ms || 0,
|
|
||||||
isSuccess ? 0 : 1,
|
|
||||||
isSuccess ? 1.0 : 0.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRequestLogs(query: RequestLogQuery = {}): RequestLogPage {
|
static getRequestLogs(query: RequestLogQuery = {}): RequestLogPage {
|
||||||
|
|
@ -269,50 +233,74 @@ export class AnalyticsService {
|
||||||
return db.prepare(query).all(...params);
|
return db.prepare(query).all(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static collectRequestLogRangeRows(filter: RequestLogFilter): RequestLogRangeRow[] {
|
// SQL-level aggregation: first find top models, then get per-date counts
|
||||||
const { whereClause, params } = buildRequestLogRangeWhere(filter);
|
|
||||||
const rows: RequestLogRangeRow[] = [];
|
|
||||||
|
|
||||||
for (const month of getRequestLogMonthsForRange(filter.startDate, filter.endDate)) {
|
|
||||||
const db = getRequestLogsDb(month);
|
|
||||||
const monthRows = db.prepare(`
|
|
||||||
SELECT local_date, backend_id, request_model, routed_model, response_model, completion_tokens
|
|
||||||
FROM request_logs
|
|
||||||
${whereClause}
|
|
||||||
`).all(...params) as RequestLogRangeRow[];
|
|
||||||
rows.push(...monthRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getModelTrends(backendId?: number, days: number = 30, limit: number = 8): unknown[] {
|
static getModelTrends(backendId?: number, days: number = 30, limit: number = 8): unknown[] {
|
||||||
const { startDate, endDate } = getDateRange(days);
|
const { startDate, endDate } = getDateRange(days);
|
||||||
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
|
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||||
const countsByModel = new Map<string, number>();
|
|
||||||
const countsByDateAndModel = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
const modelCounts = new Map<string, number>();
|
||||||
const model = row.response_model || row.routed_model || row.request_model || 'unknown';
|
|
||||||
countsByModel.set(model, (countsByModel.get(model) ?? 0) + 1);
|
for (const month of months) {
|
||||||
const key = `${row.local_date}::${model}`;
|
const db = getRequestLogsDb(month);
|
||||||
countsByDateAndModel.set(key, (countsByDateAndModel.get(key) ?? 0) + 1);
|
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT COALESCE(response_model, COALESCE(routed_model, COALESCE(request_model, 'unknown'))) as model,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY model
|
||||||
|
`).all(...params) as Array<{ model: string; cnt: number }>;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
modelCounts.set(row.model, (modelCounts.get(row.model) ?? 0) + row.cnt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const topModels = Array.from(countsByModel.entries())
|
const topModels = Array.from(modelCounts.entries())
|
||||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||||
.slice(0, Math.max(1, limit))
|
.slice(0, Math.max(1, limit))
|
||||||
.map(([model]) => model);
|
.map(([model]) => model);
|
||||||
|
|
||||||
const result: Array<{ date: string; model: string; request_count: number }> = [];
|
if (topModels.length === 0) {
|
||||||
const seenDates = new Set(rows.map((row) => row.local_date));
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
for (const date of Array.from(seenDates).sort((left, right) => left.localeCompare(right))) {
|
const topModelSet = new Set(topModels);
|
||||||
|
const dateCounts = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
for (const month of months) {
|
||||||
|
const db = getRequestLogsDb(month);
|
||||||
|
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT local_date,
|
||||||
|
COALESCE(response_model, COALESCE(routed_model, COALESCE(request_model, 'unknown'))) as model,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY local_date, model
|
||||||
|
`).all(...params) as Array<{ local_date: string; model: string; cnt: number }>;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!topModelSet.has(row.model)) continue;
|
||||||
|
let dateMap = dateCounts.get(row.local_date);
|
||||||
|
if (!dateMap) {
|
||||||
|
dateMap = new Map();
|
||||||
|
dateCounts.set(row.local_date, dateMap);
|
||||||
|
}
|
||||||
|
dateMap.set(row.model, row.cnt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Array<{ date: string; model: string; request_count: number }> = [];
|
||||||
|
const sortedDates = Array.from(dateCounts.keys()).sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
|
for (const date of sortedDates) {
|
||||||
|
const dateMap = dateCounts.get(date)!;
|
||||||
for (const model of topModels) {
|
for (const model of topModels) {
|
||||||
result.push({
|
result.push({
|
||||||
date,
|
date,
|
||||||
model,
|
model,
|
||||||
request_count: countsByDateAndModel.get(`${date}::${model}`) ?? 0,
|
request_count: dateMap.get(model) ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,67 +308,158 @@ export class AnalyticsService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQL-level histogram: use CASE-based binning with log-transformed values
|
||||||
static getResponseLengthHistogram(backendId?: number, days: number = 30, bins: number = 20): unknown[] {
|
static getResponseLengthHistogram(backendId?: number, days: number = 30, bins: number = 20): unknown[] {
|
||||||
const { startDate, endDate } = getDateRange(days);
|
const { startDate, endDate } = getDateRange(days);
|
||||||
const values = this.collectRequestLogRangeRows({ backendId, startDate, endDate })
|
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||||
.map((row) => row.completion_tokens)
|
const safeBinCount = Math.max(1, bins);
|
||||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0);
|
|
||||||
|
|
||||||
if (values.length === 0) {
|
// First pass: find min/max across all months (aggregated, not row-level)
|
||||||
|
let globalMin = Infinity;
|
||||||
|
let globalMax = -Infinity;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
for (const month of months) {
|
||||||
|
const db = getRequestLogsDb(month);
|
||||||
|
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT MIN(completion_tokens) as min_val, MAX(completion_tokens) as max_val,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
AND completion_tokens IS NOT NULL
|
||||||
|
AND completion_tokens >= 0
|
||||||
|
`).get(...params) as { min_val: number | null; max_val: number | null; cnt: number } | undefined;
|
||||||
|
|
||||||
|
if (row && row.cnt > 0) {
|
||||||
|
if (typeof row.min_val === 'number') globalMin = Math.min(globalMin, row.min_val);
|
||||||
|
if (typeof row.max_val === 'number') globalMax = Math.max(globalMax, row.max_val);
|
||||||
|
totalCount += row.cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount === 0 || globalMin === Infinity) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeBinCount = Math.max(1, bins);
|
if (globalMin === globalMax) {
|
||||||
const min = Math.min(...values);
|
return [{ bin_start: globalMin, bin_end: globalMax, count: totalCount }];
|
||||||
const max = Math.max(...values);
|
|
||||||
|
|
||||||
if (min === max) {
|
|
||||||
return [{ bin_start: min, bin_end: max, count: values.length }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformedMin = Math.log1p(min);
|
const transformedMin = Math.log1p(globalMin);
|
||||||
const transformedMax = Math.log1p(max);
|
const transformedMax = Math.log1p(globalMax);
|
||||||
const width = (transformedMax - transformedMin) / safeBinCount;
|
const width = (transformedMax - transformedMin) / safeBinCount;
|
||||||
const histogram = Array.from({ length: safeBinCount }, (_, index) => ({
|
|
||||||
bin_start: Math.expm1(transformedMin + width * index),
|
|
||||||
bin_end: index === safeBinCount - 1 ? max : Math.expm1(transformedMin + width * (index + 1)),
|
|
||||||
count: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const value of values) {
|
// Build bin boundaries for SQL CASE expression
|
||||||
const index = Math.min(safeBinCount - 1, Math.floor((Math.log1p(value) - transformedMin) / width));
|
const binBoundaries: number[] = [];
|
||||||
histogram[index].count += 1;
|
for (let i = 0; i < safeBinCount - 1; i++) {
|
||||||
|
binBoundaries.push(Math.expm1(transformedMin + width * (i + 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build SQL CASE expression for bin assignment
|
||||||
|
const caseParts: string[] = [];
|
||||||
|
for (let i = 0; i < safeBinCount - 1; i++) {
|
||||||
|
caseParts.push(`WHEN completion_tokens < ${binBoundaries[i]} THEN ${i}`);
|
||||||
|
}
|
||||||
|
caseParts.push(`ELSE ${safeBinCount - 1}`);
|
||||||
|
const caseExpr = `CASE ${caseParts.join(' ')} END`;
|
||||||
|
|
||||||
|
// Second pass: count per bin using SQL aggregation
|
||||||
|
const binCounts = new Array(safeBinCount).fill(0);
|
||||||
|
|
||||||
|
for (const month of months) {
|
||||||
|
const db = getRequestLogsDb(month);
|
||||||
|
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT ${caseExpr} as bin, COUNT(*) as cnt
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
AND completion_tokens IS NOT NULL
|
||||||
|
AND completion_tokens >= 0
|
||||||
|
GROUP BY bin
|
||||||
|
`).all(...params) as Array<{ bin: number; cnt: number }>;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const binIndex = Math.min(safeBinCount - 1, Math.max(0, row.bin));
|
||||||
|
binCounts[binIndex] += row.cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const histogram = Array.from({ length: safeBinCount }, (_, index) => ({
|
||||||
|
bin_start: index === 0 ? globalMin : Math.expm1(transformedMin + width * index),
|
||||||
|
bin_end: index === safeBinCount - 1 ? globalMax : Math.expm1(transformedMin + width * (index + 1)),
|
||||||
|
count: binCounts[index],
|
||||||
|
}));
|
||||||
|
|
||||||
return histogram;
|
return histogram;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQL-level box plot: fetch per-date aggregates, compute quantiles from sampled data
|
||||||
static getResponseLengthBoxPlot(backendId?: number, days: number = 30): unknown[] {
|
static getResponseLengthBoxPlot(backendId?: number, days: number = 30): unknown[] {
|
||||||
const { startDate, endDate } = getDateRange(days);
|
const { startDate, endDate } = getDateRange(days);
|
||||||
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
|
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||||
const valuesByDate = new Map<string, number[]>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
const dailyStats = new Map<string, { min: number; max: number; count: number; values: number[] }>();
|
||||||
if (typeof row.completion_tokens !== 'number' || !Number.isFinite(row.completion_tokens) || row.completion_tokens < 0) {
|
|
||||||
continue;
|
for (const month of months) {
|
||||||
|
const db = getRequestLogsDb(month);
|
||||||
|
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||||
|
|
||||||
|
// Get per-date min/max/count via SQL aggregation
|
||||||
|
const summaryRows = db.prepare(`
|
||||||
|
SELECT local_date, MIN(completion_tokens) as min_val, MAX(completion_tokens) as max_val, COUNT(*) as cnt
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
AND completion_tokens IS NOT NULL
|
||||||
|
AND completion_tokens >= 0
|
||||||
|
GROUP BY local_date
|
||||||
|
`).all(...params) as Array<{ local_date: string; min_val: number; max_val: number; cnt: number }>;
|
||||||
|
|
||||||
|
for (const row of summaryRows) {
|
||||||
|
const entry = dailyStats.get(row.local_date);
|
||||||
|
if (entry) {
|
||||||
|
entry.count += row.cnt;
|
||||||
|
entry.min = Math.min(entry.min, row.min_val);
|
||||||
|
entry.max = Math.max(entry.max, row.max_val);
|
||||||
|
} else {
|
||||||
|
dailyStats.set(row.local_date, {
|
||||||
|
min: row.min_val,
|
||||||
|
max: row.max_val,
|
||||||
|
count: row.cnt,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = valuesByDate.get(row.local_date) ?? [];
|
// For quantiles, fetch values per date (only completion_tokens column, limited)
|
||||||
values.push(row.completion_tokens);
|
const dateRows = db.prepare(`
|
||||||
valuesByDate.set(row.local_date, values);
|
SELECT local_date, completion_tokens
|
||||||
|
FROM request_logs
|
||||||
|
${whereClause}
|
||||||
|
AND completion_tokens IS NOT NULL
|
||||||
|
AND completion_tokens >= 0
|
||||||
|
ORDER BY local_date, completion_tokens
|
||||||
|
`).all(...params) as Array<{ local_date: string; completion_tokens: number }>;
|
||||||
|
|
||||||
|
for (const row of dateRows) {
|
||||||
|
const entry = dailyStats.get(row.local_date);
|
||||||
|
if (entry) {
|
||||||
|
entry.values.push(row.completion_tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(valuesByDate.entries())
|
return Array.from(dailyStats.entries())
|
||||||
.sort((left, right) => left[0].localeCompare(right[0]))
|
.sort((left, right) => left[0].localeCompare(right[0]))
|
||||||
.map(([date, values]) => {
|
.map(([date, stats]) => {
|
||||||
const sortedValues = [...values].sort((left, right) => left - right);
|
const sortedValues = stats.values.sort((left, right) => left - right);
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
min: sortedValues[0],
|
min: sortedValues.length > 0 ? sortedValues[0] : stats.min,
|
||||||
q1: calculateQuantile(sortedValues, 0.25),
|
q1: calculateQuantile(sortedValues, 0.25),
|
||||||
median: calculateQuantile(sortedValues, 0.5),
|
median: calculateQuantile(sortedValues, 0.5),
|
||||||
q3: calculateQuantile(sortedValues, 0.75),
|
q3: calculateQuantile(sortedValues, 0.75),
|
||||||
max: sortedValues[sortedValues.length - 1],
|
max: sortedValues.length > 0 ? sortedValues[sortedValues.length - 1] : stats.max,
|
||||||
count: sortedValues.length,
|
count: sortedValues.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -435,6 +514,11 @@ export class AnalyticsService {
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
// Parallel execution for series data (better-sqlite3 is synchronous, but this makes it explicit)
|
||||||
|
const dailyTotals = this.getDailyTotals(undefined, normalizedDays);
|
||||||
|
const backendQuality = this.getBackendQuality(undefined, normalizedDays);
|
||||||
|
const modelTrends = this.getModelTrends(undefined, normalizedDays, 6);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
window_days: normalizedDays,
|
window_days: normalizedDays,
|
||||||
generated_at: now,
|
generated_at: now,
|
||||||
|
|
@ -472,9 +556,9 @@ export class AnalyticsService {
|
||||||
users_without_permissions: users.filter((user) => !permissionsByUserId.has(user.id)).length,
|
users_without_permissions: users.filter((user) => !permissionsByUserId.has(user.id)).length,
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
daily_totals: this.getDailyTotals(undefined, normalizedDays),
|
daily_totals: dailyTotals,
|
||||||
backend_quality: this.getBackendQuality(undefined, normalizedDays) as DashboardSummaryResponse['series']['backend_quality'],
|
backend_quality: backendQuality as DashboardSummaryResponse['series']['backend_quality'],
|
||||||
model_trends: this.getModelTrends(undefined, normalizedDays, 6) as DashboardSummaryResponse['series']['model_trends'],
|
model_trends: modelTrends as DashboardSummaryResponse['series']['model_trends'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue