UNPKG

@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
/** * 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