@inso_web/els-mcp
Version:
MCP-сервер поверх INSO Error Logs Service. Read-only tools (search, analytics, fingerprinting, correlations) для подключения Claude Desktop/Code и ChatGPT к логам ошибок. Streamable HTTP transport + stdio для npx-запуска.
126 lines • 5.23 kB
JavaScript
/**
* Usage tracker.
*
* Пишет напрямую в Postgres `mcp_billing.usage_daily` (UPSERT per app+date+tool).
* Со временем будет заменён на Redis INCR + hourly flush — интерфейс останется
* тем же, так что вызывающий код не нужно будет править.
*
* Поведение:
* - Silent fail при ошибке БД (как и audit) — usage tracking не блокирует tool-call.
* - Fire-and-forget: caller вызывает через `void trackUsage(...)`.
* - AI-tools (см. `isAiTool` из `./limits.ts`) дополнительно учитываются в
* отдельной AI-квоте через `aiTodayUsage` + `checkAiQuota`.
*/
import { getPrisma } from '../audit/prisma.js';
import { decideQuota, decideAiQuota, isAiTool } from './limits.js';
function utcDateOnly(d) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export function createUsageTracker(opts = {}) {
const log = opts.log;
async function getClient() {
if (opts.prismaOverride !== undefined)
return opts.prismaOverride;
return getPrisma({ ...(opts.databaseUrl ? { databaseUrl: opts.databaseUrl } : {}), ...(log ? { log } : {}) });
}
return {
async trackUsage(p) {
const client = await getClient();
if (!client)
return;
const date = utcDateOnly(p.date ?? new Date());
try {
await client.mcpUsageDaily.upsert({
where: {
appId_date_tool: { appId: p.appId, date, tool: p.tool },
},
update: {
count: { increment: BigInt(1) },
bytesOut: { increment: BigInt(Math.max(0, Math.floor(p.bytesOut))) },
},
create: {
appId: p.appId,
date,
tool: p.tool,
count: BigInt(1),
bytesOut: BigInt(Math.max(0, Math.floor(p.bytesOut))),
},
});
}
catch (err) {
log?.warn?.({ err, tool: p.tool, appId: p.appId }, 'Usage tracking failed (non-blocking)');
}
},
async todayUsage(appId, now = new Date()) {
const client = await getClient();
if (!client)
return 0;
const date = utcDateOnly(now);
try {
const result = await client.mcpUsageDaily.aggregate({
where: { appId, date },
_sum: { count: true },
});
const total = result._sum.count;
if (total === null)
return 0;
// BigInt → number. Дневной лимит макс. 500k для PREMIUM, далее ∞ для
// UNLIMITED, поэтому safe-integer достаточно.
return Number(total);
}
catch (err) {
log?.warn?.({ err, appId }, 'todayUsage read failed; defaulting to 0');
return 0;
}
},
async checkQuota(appId, tier, now = new Date()) {
if (tier === 'UNLIMITED') {
return { allowed: true, remaining: Number.POSITIVE_INFINITY, tier };
}
const used = await this.todayUsage(appId, now);
return decideQuota(used, tier);
},
async aiTodayUsage(appId, now = new Date()) {
const client = await getClient();
if (!client)
return 0;
const date = utcDateOnly(now);
const aiTools = Array.from((await import('./limits.js')).AI_TOOL_NAMES);
if (aiTools.length === 0)
return 0;
try {
const result = await client.mcpUsageDaily.aggregate({
where: { appId, date, tool: { in: aiTools } },
_sum: { count: true },
});
const total = result._sum.count;
if (total === null)
return 0;
return Number(total);
}
catch (err) {
log?.warn?.({ err, appId }, 'aiTodayUsage read failed; defaulting to 0');
return 0;
}
},
async checkAiQuota(appId, tier, now = new Date()) {
if (tier === 'UNLIMITED') {
return { allowed: true, remaining: Number.POSITIVE_INFINITY, tier };
}
const used = await this.aiTodayUsage(appId, now);
return decideAiQuota(used, tier);
},
};
}
// Mark used (avoid unused-import warning when isAiTool is referenced only at call sites).
void isAiTool;
let globalTracker = null;
export function getUsageTracker(opts = {}) {
if (!globalTracker)
globalTracker = createUsageTracker(opts);
return globalTracker;
}
export function setUsageTrackerForTests(t) {
globalTracker = t;
}
//# sourceMappingURL=tracker.js.map