The DEV front page this week is full of confident "AI features that actually work" demos, and that is exactly the mood in which a new engineer breaks something. My first week on a mobile AI repo looked productive from the outside: simulator open, record button tapped, replies streaming in. What I never saw was the shared test key quietly shrinking behind each retry, and neither did the person paying for it.
On a server you can watch the bill climb. On a phone the damage is invisible: a screen that spins, an automatic retry you did not write, a backgrounded request that keeps streaming after you walked away. Nobody asks "what did that cost?" during onboarding, because nobody instrumented it. So make the meter your first PR, not your fourth.
Why a junior should own the meter, not the model
Quota exhaustion is the one failure class that does not throw. A crash gives you a stack trace and a teammate who has seen it before; a leaked token budget gives you silence until a demo fails in front of someone important. The person most likely to generate that traffic is the person with the freshest fingers on the record button.
Owning the meter forces you to read the request path end to end before you change it. You learn where the device id comes from, how cancellation propagates, what happens when the OS kills your socket, and which calls are yours versus the SDK's. That is a better first week than shipping a prompt tweak nobody asked for.
It also makes your later PRs reviewable. "This retry adds at most N metered calls per user action" is a sentence a senior can approve or reject. "Should be fine" is not.
The artifact: three small pieces
You need a proxy that logs usage, a client that never retries silently, and a ledger query you can run in one line. Nothing here requires production access, and nothing here needs a cloud budget if you use the free server option.
This is where MonkeyCode fits honestly. It offers free model access with an operator-stated 10 million token allocation, plus a free server option you can use to host the meter instead of asking for a staging VM on day two. I have not independently verified current quotas or terms, so check them yourself before you plan around them; the point is that a sandbox budget for onboarding is exactly what this measurement work needs.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
1. The proxy (Node 20+, run it on the free server)
// meter/server.js
import express from "express";
import fs from "node:fs";
const app = express();
app.use(express.json({ limit: "1mb" }));
const LEDGER = "./ledger.jsonl";
const UPSTREAM = process.env.UPSTREAM_URL;
const UPSTREAM_KEY = process.env.UPSTREAM_KEY;
const DAY_CAP = Number(process.env.DAY_CAP ?? 20000); // estimated tokens, per device
// Deterministic and deliberately pessimistic. It is an estimate, never a receipt.
const estimateTokens = (text = "") => Math.ceil(text.length / 3);
app.post("/v1/chat", async (req, res) => {
const { deviceId, requestId, messages } = req.body ?? {};
if (!deviceId || !requestId || !Array.isArray(messages)) {
return res.status(400).json({ error: "deviceId, requestId, messages required" });
}
const promptTokens = messages.reduce((n, m) => n + estimateTokens(m.content), 0);
const startedAt = Date.now();
const ac = new AbortController();
req.on("close", () => ac.abort()); // the phone left: stop paying upstream
try {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${UPSTREAM_KEY}` },
body: JSON.stringify({ messages }),
signal: ac.signal,
});
const json = await upstream.json();
const text = json?.choices?.[0]?.message?.content ?? "";
fs.appendFileSync(LEDGER, JSON.stringify({
ts: new Date().toISOString(), deviceId, requestId,
status: upstream.status, aborted: false, promptTokens,
completionTokens: estimateTokens(text), ms: Date.now() - startedAt,
}) + "\n");
return res.json({ requestId, text });
} catch (err) {
fs.appendFileSync(LEDGER, JSON.stringify({
ts: new Date().toISOString(), deviceId, requestId,
status: "aborted_or_failed", aborted: ac.signal.aborted,
promptTokens, completionTokens: 0, ms: Date.now() - startedAt,
error: String(err?.name ?? err),
}) + "\n");
if (!res.headersSent) res.status(499).json({ requestId, error: "client_gone" });
}
});
app.get("/v1/ledger/today", (_req, res) => {
const today = new Date().toISOString().slice(0, 10);
const rows = fs.existsSync(LEDGER)
? fs.readFileSync(LEDGER, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse)
: [];
const mine = rows.filter((r) => r.ts.startsWith(today));
const estimatedTokens = mine.reduce((n, r) => n + r.promptTokens + r.completionTokens, 0);
res.json({ requests: mine.length, estimatedTokens, cap: DAY_CAP });
});
app.listen(process.env.PORT ?? 8080);
2. The client with no silent retry
// app/src/net/meteredChat.ts
type Msg = { role: "user" | "assistant"; content: string };
let seq = 0;
export async function meteredChat(messages: Msg[], deviceId: string) {
const requestId = `${deviceId}-${Date.now()}-${++seq}`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 15_000); // a ceiling, not a retry budget
try {
const res = await fetch(`${process.env.METER_URL}/v1/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ deviceId, requestId, messages }),
signal: ac.signal,
});
if (!res.ok) throw new Error(`meter_${res.status}`);
return (await res.json()) as { requestId: string; text: string };
} finally {
clearTimeout(timer);
}
}
3. The one-line check
curl -s "$METER_URL/v1/ledger/today" | jq .
Run it after every experiment. If requests grows while you are not touching the phone, you have found a background leak on your first week instead of your third month.
The lifecycle test plan you actually run
I have not run this on a physical device for this article, so the results column is yours to fill. Use a dev build that reads METER_URL from config, never a release build.
| Condition | Exact step | Expected observation | Your result |
|---|---|---|---|
| Airplane mode mid-stream | Toggle airplane mode 2s after send | One ledger row, aborted_or_failed, no upstream completion |
|
| Background 30s | Press home during stream, wait, return | Stream ends or resumes once; ledger never shows two completions for one requestId
|
|
| Permission revoke | Revoke microphone in Settings, relaunch | No new request until permission restored; no queued replay | |
| Process kill | Swipe the app away mid-request | Server logs client_gone once; zero retries on next launch |
|
| Low power mode | Enable, repeat the same prompt | Same request count; timing may differ, so record it | |
| Network swap | Wi-Fi to cellular mid-request | Exactly one outcome, never a duplicate requestId
|
Record device, OS build, framework and dependency versions, permission state, network, and power state next to each row. If a row is empty, say it is empty; a missing measurement is data too.
When to use which setup
| Situation | Use |
|---|---|
| Learning the request path, need real model output | Free server proxy plus free model access |
| Testing cancellation and timeouts only | A local mock that returns slowly on purpose |
| Measuring battery or thermal cost | On-device path, physical phone, charger state recorded |
| Anything with real user data | Not this harness; talk to whoever owns the data policy |
Limitations and who should skip this
Token counts here are character-based estimates, so they are useful for spotting leaks and useless for billing reconciliation. The proxy holds a single upstream key, so it is a development tool and never a public endpoint; add auth before it touches a shared network. And if your product ships inference fully on-device, a network meter solves a problem you do not have.
Skip this approach if you are already inside a metered platform with per-request accounting, or if your team's policy forbids routing prompts through an additional hop. In those cases, log locally and reconcile later.
If you try the lifecycle table, post the device, OS version, the transition steps, and whether the request recovered, restarted, or silently disappeared. Comparable evidence from one more phone is worth more than another demo that looks great on a simulator.
Top comments (0)