Diagnosing AdMob Mediation Fill Rate with an Antigravity Agent — A Memo on the Revenue I Lost Chasing eCPM Alone
When AdMob mediation revenue stalls, the cause is usually fill rate, not eCPM. This memo walks through a diagnostic pipeline that lets an Antigravity agent read your BigQuery export and Firebase metrics to isolate per-network fill rate drops automatically, with real numbers from indie development.
On the AdMob dashboard, eCPM is up month over month, yet month-end revenue is somehow down. I have been shipping apps as an indie developer since 2014, and across the wallpaper apps that make up most of my roughly 50 million cumulative downloads, this "the numbers look good but I'm not earning" trap has caught me more than once. The culprit was almost never eCPM. It was the much quieter fill rate.
eCPM tells you the unit price of an ad that was shown. It says nothing about whether an ad was shown at all. When you run mediation across several networks, one network's fill rate can drift downward in silence: requests keep going out, but more and more of them come back empty. Stare at the eCPM graph alone and this hole stays invisible.
So I built a setup where an Antigravity agent reads my AdMob metrics every morning and isolates fill rate anomalies network by network. Here is the design and implementation of that diagnostic pipeline, including the places I tripped over.
Fill rate and eCPM tell different stories
Let me first untangle two metrics that are easy to conflate. In AdMob mediation, a single ad slot runs through this flow: the app requests an ad (request), mediation polls each network in order, one of them returns an ad (match), and it is actually shown on screen (impression).
Fill rate (match rate) is match ÷ request — the share of requests that came back with an ad. eCPM is revenue per 1,000 impressions, so its denominator is impressions. That is the crux: eCPM never counts the requests that failed to fill.
Consider a concrete case. A network can have a high eCPM of ¥800, but if its fill rate has slipped to 40%, the real revenue per 1,000 requests works out to only about ¥320. Meanwhile a network at ¥500 eCPM with 90% fill rate earns roughly ¥450 per 1,000 requests. If you set your waterfall priority by eCPM ranking alone, you place the former above the latter and leak revenue. That is exactly the mistake I made first.
This is why the diagnostic agent needs to surface an effective rate that multiplies eCPM by fill rate, broken down by network, hour, and country. Tracking that by hand on the AdMob dashboard every day is not realistic. That is where the agent earns its place.
What to feed the diagnostic agent — designing the data sources
The first decision is the data source. You could scrape the AdMob dashboard, but for stability I combine AdMob's BigQuery export (the mediation schema) with Firebase Analytics metrics.
My guiding principle is simple: pull the raw numbers from a structured source, and let the agent handle only the prose interpretation. If you make the agent read HTML and pick out numbers, you invite digit errors and missing values. Fetch the figures with deterministic code, and delegate only "how should I read this number" to the agent. That is the conclusion I reached in practice.
Specifically I bind three things. From the AdMob BigQuery table, daily per-network request / match / impression / estimated_earnings. From Firebase, per-app DAU and ad impression event counts. And my own waterfall configuration (which networks are ordered in what sequence), held in YAML as material for the agent's judgment.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦For a wallpaper app where eCPM is high but revenue won't grow, learn to have an Antigravity agent isolate per-network fill rate drops and pinpoint the cause in about fifteen minutes
✦Take home a working Python implementation that binds AdMob's BigQuery export and Firebase metrics into a single agent tool and reads impression / request / match rate every morning
✦Learn exactly where to draw the line between what the agent decides and what you decide, from a revenue mistake I actually made in production
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Now the implementation. First, a function that pulls the metrics from BigQuery and computes fill rate and effective rate. This part must run deterministically, so it lives outside the agent as plain Python.
from google.cloud import bigqueryfrom dataclasses import dataclass@dataclassclass NetworkMetrics: network: str requests: int matches: int impressions: int earnings: float # JPY @property def fill_rate(self) -> float: return self.matches / self.requests if self.requests else 0.0 @property def show_rate(self) -> float: # Inverse of "matched but never shown" return self.impressions / self.matches if self.matches else 0.0 @property def ecpm(self) -> float: return (self.earnings / self.impressions * 1000) if self.impressions else 0.0 @property def effective_rpm(self) -> float: # Real revenue per 1,000 requests (fill rate baked in) return (self.earnings / self.requests * 1000) if self.requests else 0.0def fetch_metrics(client: bigquery.Client, dataset: str, day: str) -> list[NetworkMetrics]: query = f""" SELECT ad_source_name AS network, SUM(ad_request_count) AS requests, SUM(matched_request_count) AS matches, SUM(impression_count) AS impressions, SUM(estimated_earnings_micros) / 1e6 AS earnings FROM `{dataset}.mediation_report` WHERE date = @day GROUP BY network HAVING requests > 0 ORDER BY earnings DESC """ job = client.query( query, job_config=bigquery.QueryJobConfig( query_parameters=[bigquery.ScalarQueryParameter("day", "DATE", day)] ), ) return [NetworkMetrics(**dict(row)) for row in job.result()]
effective_rpm is the star of this article. By computing a per-request unit rate instead of eCPM, the loss from unfilled requests lands directly in the number.
Next, register this metric set as a tool the agent can call. In an Antigravity agent definition, the key is to cleanly separate the role of "a function that returns a deterministic calculation" from "the LLM that interprets the result."
def diagnose_fill_rate(day: str, waterfall: dict) -> dict: client = bigquery.Client() metrics = fetch_metrics(client, "admob_export_123456", day) findings = [] for m in metrics: configured_rank = waterfall.get(m.network, 999) findings.append({ "network": m.network, "fill_rate": round(m.fill_rate, 3), "ecpm_jpy": round(m.ecpm, 1), "effective_rpm_jpy": round(m.effective_rpm, 1), "configured_rank": configured_rank, }) # Re-rank by effective rate and measure the gap vs configured rank by_effective = sorted(findings, key=lambda f: -f["effective_rpm_jpy"]) for actual_rank, f in enumerate(by_effective, start=1): f["effective_rank"] = actual_rank f["rank_gap"] = f["configured_rank"] - actual_rank return {"day": day, "networks": by_effective}
A network with a large rank_gap — ranked high in configuration but low by effective rate — is the prime suspect for leaking revenue. I hand this structured result to the agent and have it describe, in natural language, which network's fill rate dropped and when, and what the likely cause is.
The logic for isolating a fill rate drop
A fill rate drop never has a single cause. Before handing things to the agent, I embed in the prompt the diagnostic axes I worked out from experience.
First: is only one network down, or is everything down? If everything is down, suspect your own side (SDK version, network configuration, regional inventory shortage); if it is a single network, suspect the other side's inventory or floor price settings. Second: is it time-of-day dependent? A drop only at night may mean your floor price is too high for hours when inventory is thin. Third: is there a country skew? If fill rate is extremely low in one country, often that network simply has little inventory there.
I write these three axes into the tool's description so the agent can reference them. The procedure looks like this:
Compare each network's fill rate across the last 7 days versus the prior 7 days and extract drops of 5 points or more
For networks that dropped, re-query the hour-of-day and per-country breakdown
Flag networks where rank_gap is positive (overvalued) and fill rate has dropped as high-priority alerts to notify a human
The important thing here is to not let the agent assert "lower the floor price." Lowering the floor price raises fill rate but lowers eCPM. As I describe below, that trade-off is a decision a human should keep hold of.
Before / After: the diagnostic prompt I actually rewrote
My first prompt made a common mistake.
# Before (the version that didn't work)You are an AdMob expert. Analyze the mediation report belowand propose improvements to maximize revenue.{metrics_json}
With this prompt, the agent just returned plausible generalities every time ("optimize your floor prices," "add a new network") and never made a specific observation about today's numbers. Generalities are worthless to me. What I want is "what changed compared to yesterday."
In the rewrite, I fixed both the judgment axes and the output format.
# After (the version that became useful)You are a diagnostic assistant supporting an indie developer's mediationoperations. The input is per-network metrics, today versus yesterday.Execute only the following steps.1. List only networks whose fill rate dropped by 5pt or more vs yesterday (if none dropped, reply in one line: "No significant fill rate drop today")2. For each, look at rank_gap and state whether it is overvalued3. Classify the suspected cause as "inventory," "floor price," or "regional skew"4. Propose exactly one action for a human to verify (do not propose setting changes)Do not write generic improvement theory or textbook advice.{metrics_json}
The instruction to "reply in one line if nothing dropped" is what made the difference. The Before version returned long text even on days with no anomaly, I got into the habit of skimming past it, and as a result I skimmed past the report on a day that actually did have an anomaly. Once I changed the design so that quiet days stay quiet, the reliability of the alerts came back.
What I tripped over in production, and the workarounds
Running it every morning surfaced a few pitfalls.
The first was the BigQuery export delay. In my environment, the previous day's AdMob data did not settle in BigQuery until around midday. Running it in the early morning produces a false alarm — "fill rate crashed to 0%" — while the prior day is still incomplete. The fix was to fix the query target to "two days ago" and look only at finalized data. A choice of accuracy over freshness.
The second was that a newly added network shows an extremely low initial fill rate. There is a calibration period right after integration during which fill rate is unstable. To keep this from being misread as an "anomalous drop," I added a flag in the waterfall YAML to exclude networks with fewer than 7 days of runtime from the comparison.
The third is the obvious lesson that you must not believe the agent's output as-is. Once it asserted "Network A inventory exhaustion," so I was about to change settings — but the real cause was an adapter misconfiguration from my own SDK update the day before. The agent structurally overlooks cases where the cause is on your own side. So I always attach a checklist item to the output: "confirm there were no recent releases or config changes." In production, building a sentence that doubts the agent's conclusion into the system turned out to be the single most effective thing.
What to delegate to the agent, and what a human decides
This is where I agonized most in operation. Technically, you could automate all the way to automatically lowering the floor price when fill rate drops. I have seen cases that do exactly that. But I keep settings changes that bear directly on revenue out of the agent's hands.
There are two reasons. The trade-off between floor price and fill rate is inseparable from the user experience of that app and from my judgment, as a creator, about how much advertising I even want to show. Some of my wallpaper apps are made with the idea that people should use them quietly, and revenue maximization is not always the right answer. The other reason is that if settings are rewritten automatically, you lose the ability to trace later why revenue moved. Separating diagnosis from execution, and keeping the execution log in human hands, is a safety mechanism for operating solo over the long run.
So my line is clear. The agent handles observation, comparison, cause classification, and notification. Decisions tied to revenue and experience — what to set the floor price to, whether to cut a network — I keep. Drawing that boundary up front lets you trust the agent without leaning on it too much. My curiosity about technology has been my engine ever since I first touched the internet in 1997, but not handing that energy all the way to the final decision is, I feel, important for sustaining indie development.
The next step
If you run mediation across several networks the same way, start by checking just one thing in the report you already have. Look at the fill rate of your top-eCPM network; if it is below 80%, simply re-ranking by effective_rpm may change your priority order. Building the diagnostic agent can come after that. Sometimes a single small calculation reveals a hole you could not see.
I hope this helps fellow indie developers working on the same problem.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.