librechat-analytics/services/analytics.js
DJP 65824fc3b3 Initial commit: LibreChat Analytics Dashboard
Express.js + Chart.js dashboard for LibreChat usage analytics.
Queries MongoDB transactions collection for model/agent usage, costs, and top users.
Dark theme with Montserrat font, black/gold (#FFC407) color scheme.
Docker-ready with API key authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:48:40 -04:00

285 lines
8.2 KiB
JavaScript

const { getModelPricing } = require('../config/pricing');
// Cache agents collection in memory, refresh every 5 min
let agentsCache = new Map();
let agentsCacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000;
async function refreshAgentsCache(db) {
if (Date.now() - agentsCacheTime < CACHE_TTL && agentsCache.size > 0) return;
try {
const agents = await db.collection('agents').find({}).toArray();
agentsCache = new Map();
for (const a of agents) {
agentsCache.set(a.id, { name: a.name, model: a.model, provider: a.provider });
}
agentsCacheTime = Date.now();
} catch (e) {
console.error('Failed to refresh agents cache:', e.message);
}
}
function resolveModel(model) {
if (model && model.startsWith('agent_')) {
const agent = agentsCache.get(model);
return agent ? agent.model : model;
}
return model;
}
function getDateRange(query) {
const { period, start, end } = query;
const now = new Date();
let startDate, endDate;
if (period === 'custom' && start && end) {
startDate = new Date(start);
endDate = new Date(end);
endDate.setHours(23, 59, 59, 999);
} else {
endDate = now;
switch (period) {
case '7d': startDate = new Date(now - 7 * 86400000); break;
case '30d': startDate = new Date(now - 30 * 86400000); break;
case '24h':
default: startDate = new Date(now - 24 * 3600000); break;
}
}
return { startDate, endDate };
}
async function getSummary(db, query) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const [tokenResult, userCount, convCount] = await Promise.all([
db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: null,
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
}
}
]).toArray(),
db.collection('transactions').distinct('user', {
createdAt: { $gte: startDate, $lte: endDate }
}),
db.collection('transactions').distinct('conversationId', {
createdAt: { $gte: startDate, $lte: endDate }
}),
]);
const t = tokenResult[0] || { totalTokens: 0, totalCost: 0 };
return {
totalTokens: t.totalTokens,
totalCost: t.totalCost / 1_000_000,
activeUsers: userCount.length,
conversations: convCount.length,
};
}
async function getTopUsers(db, query, limit = 10) {
const { startDate, endDate } = getDateRange(query);
const results = await db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: '$user',
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
conversations: { $addToSet: '$conversationId' },
}
},
{ $sort: { totalCost: -1 } },
{ $limit: limit },
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'userInfo'
}
},
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
{
$project: {
name: { $ifNull: ['$userInfo.name', 'Unknown'] },
email: { $ifNull: ['$userInfo.email', ''] },
totalTokens: 1,
totalCost: { $divide: ['$totalCost', 1_000_000] },
conversationCount: { $size: '$conversations' },
}
}
]).toArray();
return results;
}
async function getTopModels(db, query, limit = 10) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const raw = await db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: { model: '$model', tokenType: '$tokenType' },
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
}
}
]).toArray();
// Resolve agents to underlying LLM and re-aggregate
const modelMap = new Map();
for (const r of raw) {
const resolvedModel = resolveModel(r._id.model);
const key = `${resolvedModel}::${r._id.tokenType}`;
if (!modelMap.has(key)) {
modelMap.set(key, { model: resolvedModel, tokenType: r._id.tokenType, totalTokens: 0, totalCost: 0 });
}
const entry = modelMap.get(key);
entry.totalTokens += r.totalTokens;
entry.totalCost += r.totalCost;
}
// Pivot into per-model summary
const models = new Map();
for (const entry of modelMap.values()) {
if (!models.has(entry.model)) {
models.set(entry.model, { model: entry.model, promptTokens: 0, completionTokens: 0, promptCost: 0, completionCost: 0, totalCost: 0 });
}
const m = models.get(entry.model);
if (entry.tokenType === 'prompt') {
m.promptTokens += entry.totalTokens;
m.promptCost += entry.totalCost / 1_000_000;
} else {
m.completionTokens += entry.totalTokens;
m.completionCost += entry.totalCost / 1_000_000;
}
m.totalCost = m.promptCost + m.completionCost;
}
return Array.from(models.values())
.sort((a, b) => b.totalCost - a.totalCost)
.slice(0, limit);
}
async function getTopAgents(db, query, limit = 10) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const results = await db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate }, model: { $regex: /^agent_/ } } },
{
$group: {
_id: '$model',
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
conversations: { $addToSet: '$conversationId' },
}
},
{ $sort: { totalCost: -1 } },
{ $limit: limit },
{
$project: {
agentId: '$_id',
totalTokens: 1,
totalCost: { $divide: ['$totalCost', 1_000_000] },
conversationCount: { $size: '$conversations' },
}
}
]).toArray();
return results.map(r => {
const agent = agentsCache.get(r.agentId);
return {
...r,
agentName: agent ? agent.name : r.agentId,
underlyingModel: agent ? agent.model : 'Unknown',
provider: agent ? agent.provider : 'Unknown',
};
});
}
async function getCostBreakdown(db, query) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const raw = await db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: { model: '$model', tokenType: '$tokenType' },
totalCost: { $sum: { $abs: '$tokenValue' } },
}
}
]).toArray();
const models = new Map();
for (const r of raw) {
const resolved = resolveModel(r._id.model);
if (!models.has(resolved)) {
models.set(resolved, { model: resolved, inputCost: 0, outputCost: 0 });
}
const m = models.get(resolved);
if (r._id.tokenType === 'prompt') {
m.inputCost += r.totalCost / 1_000_000;
} else {
m.outputCost += r.totalCost / 1_000_000;
}
}
return Array.from(models.values())
.map(m => ({ ...m, totalCost: m.inputCost + m.outputCost }))
.sort((a, b) => b.totalCost - a.totalCost);
}
async function getUsageOverTime(db, query) {
const { startDate, endDate } = getDateRange(query);
const diffMs = endDate - startDate;
const diffHours = diffMs / 3600000;
// Use hourly buckets for <=48h, daily for longer
let dateFormat, bucketLabel;
if (diffHours <= 48) {
dateFormat = { $dateToString: { format: '%Y-%m-%dT%H:00', date: '$createdAt' } };
bucketLabel = 'hour';
} else {
dateFormat = { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } };
bucketLabel = 'day';
}
const results = await db.collection('transactions').aggregate([
{ $match: { createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: dateFormat,
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
}
},
{ $sort: { _id: 1 } },
]).toArray();
return {
bucketType: bucketLabel,
data: results.map(r => ({
time: r._id,
tokens: r.totalTokens,
cost: r.totalCost / 1_000_000,
})),
};
}
module.exports = {
getSummary,
getTopUsers,
getTopModels,
getTopAgents,
getCostBreakdown,
getUsageOverTime,
};