// Lightweight usage counters (Cloudflare KV, free plan friendly).
// Counts are batched in memory per isolate and flushed to KV at most once per `flushMs`,
// so KV writes stay far below the free-tier limit. Counts are approximate (an isolate can be
// evicted with a few unflushed hits); on-chain USDC transfers remain the source of truth for revenue.
// Requests carrying `x-agentgate-smoke: 1` (our own CI smoke test) are never counted.
export const EVENTS = ['challenge_402', 'paid', 'paid_failed', 'free_preview', 'free_mcp', 'free_rate_limited', 'bad_request'];
const day = (t = Date.now()) => new Date(t).toISOString().slice(0, 10);
export class Stats {
/** @param {any} kv KV namespace binding (or any {get(key,'json'), put(key,value)}), may be undefined */
constructor(kv, { flushMs = 30000, now = () => Date.now() } = {}) {
this.kv = kv; this.flushMs = flushMs; this.now = now;
this.pending = {}; this.lastFlush = 0; this.cache = null;
}
get enabled() { return !!this.kv; }
/** Record one event. `waitUntil` keeps the flush alive after the response on Workers. */
hit(event, waitUntil) {
if (!this.kv) return;
const d = day(this.now());
const bucket = (this.pending[d] ||= {});
bucket[event] = (bucket[event] || 0) + 1;
if (this.now() - this.lastFlush >= this.flushMs) {
this.lastFlush = this.now();
const p = this.flush().catch(() => {});
try { waitUntil?.(p); } catch { /* not on Workers */ }
}
}
async flush() {
const pending = this.pending; this.pending = {};
for (const [d, counts] of Object.entries(pending)) {
const key = `d:${d}`;
const cur = (await this.kv.get(key, 'json')) || {};
for (const [k, v] of Object.entries(counts)) cur[k] = (cur[k] || 0) + v;
await this.kv.put(key, JSON.stringify(cur));
}
this.cache = null;
}
/** Per-day counts for the last `days` UTC days plus totals. Cached for 60 s to save KV reads. */
async summary(days = 30) {
if (!this.kv) return { enabled: false };
if (this.cache && this.now() - this.cache.at < 60000) return this.cache.value;
const out = []; const totals = {};
for (let i = 0; i < days; i++) {
const d = day(this.now() - i * 86400000);
const stored = (await this.kv.get(`d:${d}`, 'json')) || {};
const counts = { ...stored };
for (const [k, v] of Object.entries(this.pending[d] || {})) counts[k] = (counts[k] || 0) + v;
if (Object.keys(counts).length) out.push({ day: d, ...counts });
for (const [k, v] of Object.entries(counts)) totals[k] = (totals[k] || 0) + v;
}
const value = {
enabled: true, window_days: days, totals, days: out,
note: 'Approximate counters (batched per Worker isolate). Our own CI smoke tests are excluded. Paid revenue is verifiable on-chain: USDC transfers on Base to the payTo wallet.',
generated_at: new Date(this.now()).toISOString(),
};
this.cache = { at: this.now(), value };
return value;
}
}
export const isSmoke = (c) => c.req.header('x-agentgate-smoke') === '1';
export const waitUntilOf = (c) => { try { const ec = c.executionCtx; return (p) => ec.waitUntil(p); } catch { return undefined; } };
/** Hono middleware for the paid route: classify the outcome after x402 has run. Mount BEFORE paymentMiddleware. */
export function paidRouteStats(stats) {
return async (c, next) => {
await next();
if (isSmoke(c)) return;
const s = c.res.status;
const ev = s === 402 ? 'challenge_402'
: s === 200 && (c.res.headers.get('PAYMENT-RESPONSE') || c.res.headers.get('X-PAYMENT-RESPONSE')) ? 'paid'
: s === 400 ? 'bad_request'
: s >= 400 && (c.req.header('PAYMENT-SIGNATURE') || c.req.header('X-PAYMENT')) ? 'paid_failed'
: null;
if (ev) stats.hit(ev, waitUntilOf(c));
};
}
agentgate-source: code mirror on Cloudflare Pages. All repos