agentgate / tree / service/src/stats.js ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// 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