librechat-analytics/services/analytics.js
DJP 3d3c8a1a54 feat: Add Visits and Unique Users cards to overview
Visits counts total conversations created in the timeframe (from
conversations collection). Unique Users counts distinct users who
created at least one conversation in the period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:48:46 -04:00

366 lines
11 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, visits, uniqueUsers] = 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 }
}),
db.collection('conversations').countDocuments({
createdAt: { $gte: startDate, $lte: endDate }
}),
db.collection('conversations').distinct('user', {
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,
visits: visits,
uniqueUsers: uniqueUsers.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,
})),
};
}
async function getTopConversations(db, query, limit = 20, userId = null) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const match = { createdAt: { $gte: startDate, $lte: endDate } };
if (userId) match.user = new (require('mongodb').ObjectId)(userId);
const results = await db.collection('transactions').aggregate([
{ $match: match },
{
$group: {
_id: '$conversationId',
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
models: { $addToSet: '$model' },
user: { $first: '$user' },
promptTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
},
completionTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
},
}
},
{ $sort: { totalCost: -1 } },
{ $limit: limit },
{
$lookup: {
from: 'users',
localField: 'user',
foreignField: '_id',
as: 'userInfo'
}
},
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
{
$lookup: {
from: 'conversations',
localField: '_id',
foreignField: 'conversationId',
as: 'convInfo'
}
},
{ $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } },
{
$project: {
conversationId: '$_id',
title: { $ifNull: ['$convInfo.title', 'Unknown'] },
userName: { $ifNull: ['$userInfo.name', 'Unknown'] },
userEmail: { $ifNull: ['$userInfo.email', ''] },
models: 1,
totalTokens: 1,
promptTokens: 1,
completionTokens: 1,
totalCost: { $divide: ['$totalCost', 1_000_000] },
}
}
]).toArray();
// Resolve agent model names
return results.map(r => ({
...r,
models: r.models.map(m => {
if (m && m.startsWith('agent_')) {
const agent = agentsCache.get(m);
return agent ? `${agent.name} (${agent.model})` : m;
}
return m;
}),
}));
}
module.exports = {
getSummary,
getTopUsers,
getTopModels,
getTopAgents,
getCostBreakdown,
getUsageOverTime,
getTopConversations,
};