One Monday morning, I opened my AdMob dashboard to find revenue had dropped 62% compared to the day before. I didn't find out until hours later, and by then the window to respond quickly had already closed.
I've been building mobile apps independently since 2014 — wallpaper and wellness apps that have crossed 50 million downloads combined, with AdMob revenue peaking above $10,000/month at its best. At that scale, how fast you detect a revenue anomaly matters more than you'd think.
This article covers how I built an automatic anomaly detection system using Gemini API's Function Calling to pull AdMob Reporting API data, analyze it intelligently, and send a Slack alert within an hour of something going wrong.
Why Gemini Function Calling Instead of a Simple Script
My first attempt was a basic shell script: fetch yesterday's revenue, compare it to the day before, alert if it drops below a fixed threshold. It was useless within a week.
Revenue has natural variation that a fixed percentage threshold can't capture. Mondays are always lower than Fridays. Revenue spikes during sale campaigns. Public holidays distort the baseline. A 40% drop on the Monday after a long weekend is normal; the same drop on a random Thursday is a crisis.
By routing through Gemini API, I can pass contextual data — day of week, recent trend, eCPM alongside impression count — and let Gemini determine whether the change is an actual anomaly or expected variation. It's a more human-like judgment than any threshold I could hard-code.
Setting Up AdMob Reporting API Access
Enable the AdMob API in Google Cloud Console and create a service account. Download the JSON key and set your environment variables:
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
export ADMOB_PUBLISHER_ID="pub-XXXXXXXXXXXXXXXX" # Your publisher ID
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"Here's the function that fetches daily revenue data:
from google.oauth2 import service_account
from googleapiclient.discovery import build
from datetime import datetime, timedelta
import os
def fetch_admob_revenue(days_ago: int = 1) -> dict:
"""
Fetch AdMob revenue data for a specific past date.
Args:
days_ago: How many days back to fetch (1=yesterday, 7=last week)
Returns:
{"date": "YYYY-MM-DD", "revenue": float, "impressions": int, "ecpm": float}
"""
credentials = service_account.Credentials.from_service_account_file(
os.environ["GOOGLE_APPLICATION_CREDENTIALS"],
scopes=["https://www.googleapis.com/auth/admob.readonly"]
)
service = build("admob", "v1", credentials=credentials)
target_date = datetime.now() - timedelta(days=days_ago)
publisher_id = os.environ["ADMOB_PUBLISHER_ID"]
report_request = {
"reportSpec": {
"dateRange": {
"startDate": {"year": target_date.year, "month": target_date.month, "day": target_date.day},
"endDate": {"year": target_date.year, "month": target_date.month, "day": target_date.day}
},
"metrics": ["ESTIMATED_EARNINGS", "IMPRESSIONS", "IMPRESSION_RPM"],
"dimensions": []
}
}
response = service.accounts().networkReport().generate(
parent=f"accounts/{publisher_id}",
body=report_request
).execute()
revenue = 0.0
impressions = 0
ecpm = 0.0
for row in response:
if "row" in row:
m = row["row"].get("metricValues", {})
# IMPORTANT: ESTIMATED_EARNINGS is in micros (1 USD = 1,000,000 microsValue)
revenue += float(m.get("ESTIMATED_EARNINGS", {}).get("microsValue", 0)) / 1_000_000
impressions += int(m.get("IMPRESSIONS", {}).get("integerValue", 0))
ecpm = float(m.get("IMPRESSION_RPM", {}).get("doubleValue", 0))
return {
"date": target_date.strftime("%Y-%m-%d"),
"revenue": round(revenue, 2),
"impressions": impressions,
"ecpm": round(ecpm, 4)
}One thing I got wrong early on: the ESTIMATED_EARNINGS field comes back in microsValue — millionths of a dollar. Forgetting that conversion means your "revenue yesterday was $42" shows up as "$42,000,000" in your alert. My system once told me I had a great day.
Gemini Function Calling: The Core Analysis Loop
The key insight is letting Gemini decide which historical data it needs, rather than pre-computing everything yourself:
import google.generativeai as genai
import json, re, os
from datetime import datetime
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
# Declare the function Gemini can call
tools = [{
"function_declarations": [{
"name": "get_revenue_data",
"description": "Fetch AdMob revenue data for a given number of days ago.",
"parameters": {
"type": "object",
"properties": {
"days_ago": {
"type": "integer",
"description": "Days ago to fetch: 1=yesterday, 2=day before, 7=one week ago"
}
},
"required": ["days_ago"]
}
}]
}]
def run_revenue_analysis() -> dict | None:
model = genai.GenerativeModel(model_name="gemini-2.5-pro-preview-05-06", tools=tools)
today_weekday = datetime.now().strftime("%A")
prompt = f"""
Today is {today_weekday}. Use get_revenue_data to fetch revenue for:
- Yesterday (days_ago=1)
- 2 and 3 days ago
- 7 days ago (same weekday last week)
- 14 days ago
Then analyze whether yesterday's revenue is anomalous given:
- Day-over-day change
- Same-weekday-last-week comparison
- eCPM trend (a drop in eCPM with stable impressions suggests fill rate or demand-side issues)
- Natural weekend vs. weekday variation
Respond only in this JSON format:
{{
"is_anomaly": true/false,
"severity": "low/medium/high",
"yesterday_revenue": <float>,
"change_from_prev_day": <float, percent>,
"change_from_last_week": <float, percent>,
"summary": "<1-2 sentence diagnosis>",
"possible_causes": ["<cause 1>", "<cause 2>"]
}}
"""
chat = model.start_chat()
response = chat.send_message(prompt)
# Handle potentially multiple sequential function calls
while True:
part = response.candidates[0].content.parts[0]
if not part.HasField("function_call"):
break
fc = part.function_call
if fc.name == "get_revenue_data":
days_ago = fc.args["days_ago"]
data = fetch_admob_revenue(days_ago=days_ago)
print(f" 📊 {data['date']}: ${data['revenue']:.2f} ({data['impressions']:,} impressions, eCPM ${data['ecpm']:.2f})")
response = chat.send_message(
genai.protos.Content(parts=[
genai.protos.Part(function_response=genai.protos.FunctionResponse(
name="get_revenue_data",
response={"result": data}
))
])
)
json_match = re.search(r'\{.*\}', response.text, re.DOTALL)
return json.loads(json_match.group()) if json_match else NoneExpected output when an anomaly is detected:
🔍 Starting AdMob revenue analysis...
📊 2026-05-12: $42.30 (128,500 impressions, eCPM $0.33)
📊 2026-05-11: $98.20 (182,300 impressions, eCPM $0.54)
📊 2026-05-10: $91.80 (179,100 impressions, eCPM $0.51)
📊 2026-05-06: $95.40 (180,200 impressions, eCPM $0.53)
📊 2026-04-29: $93.10 (177,800 impressions, eCPM $0.52)
🔴 Anomaly detected: Revenue dropped 57% vs yesterday. eCPM fell from $0.54 to $0.33.
Yesterday's revenue: $42.30
Day-over-day: -56.9%
vs same weekday last week: -55.7%
Possible causes: Impression volume drop, eCPM demand-side issue, possible mediation misconfiguration
Adding Slack Alerts
Once you have the analysis result, sending it to Slack is straightforward:
import requests
def send_slack_alert(analysis: dict, webhook_url: str):
"""Send an alert to Slack when an anomaly is detected."""
if not analysis.get("is_anomaly"):
return
color = {"low": "#FFC107", "medium": "#FF5722", "high": "#F44336"}.get(
analysis["severity"], "#9E9E9E"
)
payload = {
"attachments": [{
"color": color,
"title": f"AdMob Revenue Alert [{analysis['severity'].upper()}]",
"text": analysis["summary"],
"fields": [
{"title": "Yesterday Revenue", "value": f"${analysis['yesterday_revenue']:.2f}", "short": True},
{"title": "Day-over-Day", "value": f"{analysis['change_from_prev_day']:+.1f}%", "short": True},
{"title": "vs. Last Week", "value": f"{analysis['change_from_last_week']:+.1f}%", "short": True},
{"title": "Possible Causes",
"value": "\n".join(f"• {c}" for c in analysis["possible_causes"]), "short": False}
],
"footer": "AdMob Anomaly Detector · Gemini API",
"ts": int(datetime.now().timestamp())
}]
}
requests.post(webhook_url, json=payload)I run this on Cloud Run with a daily cron trigger at 08:00 JST. By the time I'm on my second coffee, I already know whether the previous day's revenue was normal.
A Few Things I Learned the Hard Way
The Function Calling loop surprised me early on. Gemini sometimes issues multiple function calls in a single turn — asking for yesterday's data, then deciding mid-analysis that it also wants two weeks of data, then requesting one more day. The while True loop above handles this correctly; a single if check does not.
Another gotcha: when I first deployed this, I hit the AdMob Reporting API's rate limit (one request per second per project) during testing. The AdMob API is not designed for high-frequency polling. Running this once a day is fine; running it every 10 minutes will get you rate-limited.
For more on structuring multi-step Function Calling workflows, the Gemini API Function Calling Complete Guide covers the underlying mechanics in depth.
Next Step
Deploy this as a Cloud Run job or a cron on any VM, pointed at a Slack webhook. The whole thing runs in under 30 seconds and costs roughly $0.02–$0.05 per day in API calls — trivially cheap compared to the cost of missing a revenue anomaly for four hours.
If you're running AdMob-monetized apps, knowing within an hour beats knowing at noon.