vis: make metering service alarm only go off on actual abuse (#3208)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-06-03 22:45:18 -07:00
committed by GitHub
parent e99ae40e93
commit a480679a2d
2 changed files with 85 additions and 10 deletions
@@ -321,6 +321,79 @@ describe('MeteringService', () => {
});
});
// ── overuse alarm ────────────────────────────────────────────────
describe('overuse alarm', () => {
const wasOveruseAlarmed = (alarmSpy: ReturnType<typeof vi.spyOn>) =>
alarmSpy.mock.calls.some(
(call) =>
typeof call[0] === 'string' &&
call[0].includes('usage exceeded'),
);
it('does not alarm when a single large request crosses the limit in one shot', async () => {
const bigActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(bigActor);
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
// Previous usage was 0 (under the allowance) — one big request that
// blows past the limit is legitimate and shouldn't page.
await target.incrementUsage(
bigActor,
'ai:chat',
1,
sub.monthUsageAllowance * 5,
);
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
alarmSpy.mockRestore();
});
it('alarms on the next expense once already at or past the limit', async () => {
const overActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(overActor);
// First expense takes them exactly to the limit — no alarm yet.
await target.incrementUsage(
overActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
// Spy only on the *second* expense, which lands while already at
// the limit — this is the one that should flag.
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.incrementUsage(overActor, 'ai:chat', 1, 1_000);
expect(alarmSpy).toHaveBeenCalledWith(
expect.stringContaining('usage exceeded'),
expect.stringContaining('exceeded their usage allowance'),
expect.objectContaining({ totalUsage: expect.any(Number) }),
);
alarmSpy.mockRestore();
});
it('does not alarm while purchased credits still cover the overage', async () => {
const creditActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(creditActor);
await target.updateAddonCredit(creditActor.user.uuid, 5_000_000);
await target.incrementUsage(
creditActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.incrementUsage(creditActor, 'ai:chat', 1, 1_000);
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
alarmSpy.mockRestore();
});
});
// ── batchIncrementUsages ─────────────────────────────────────────
describe('batchIncrementUsages', () => {
@@ -893,20 +893,22 @@ export class MeteringService extends PuterService {
incrementCost,
} = ctx;
const allowedMultiple = Math.floor(
actorUsages.total / actorSubscription.monthUsageAllowance,
);
const previousMultiple = Math.floor(
(actorUsages.total - incrementCost) /
actorSubscription.monthUsageAllowance,
);
const isOver2x = allowedMultiple >= 2;
const crossedThreshold = previousMultiple < allowedMultiple;
const allowance = actorSubscription.monthUsageAllowance;
// No metered allowance to exceed (e.g. unlimited policies) — nothing to flag.
if (!(allowance > 0)) return;
// Only alarm if the actor was ALREADY at or past their allowance before
// this expense arrived. A single large request that crosses the limit in
// one shot (previous usage still under the allowance) is legitimate and
// shouldn't page — we only flag sustained overuse, i.e. the next expense
// that lands while they're already over.
const previousUsage = actorUsages.total - incrementCost;
const wasAlreadyOverLimit = previousUsage >= allowance;
const hasNoAddonCredit =
(actorAddons.purchasedCredits || 0) <=
(actorAddons.consumedPurchaseCredits || 0);
if (!(isOver2x && crossedThreshold && hasNoAddonCredit)) return;
if (!(wasAlreadyOverLimit && hasNoAddonCredit)) return;
this.clients.alarm.create(
`metering usage exceeded by user: ${actor.user?.username}`,