GEMINI LABJP
NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaplyOMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflowsAGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactionsMEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuouslyTHROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and regionDEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaplyOMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflowsAGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactionsMEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuouslyTHROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and regionDEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Articles/API / SDK
API / SDK/2026-05-13Intermediate

Building an AdMob Revenue Anomaly Detector with Gemini API Function Calling

Learn how to build an automatic AdMob revenue anomaly detection system using Gemini API Function Calling — with real Python code, practical tips from 10+ years of indie app development, and Slack alerting integration.

admob4gemini-api277function-calling20python104indie-dev43monetization21

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 None

Expected 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.

Share

Thank You for Reading

Gemini 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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API / SDK2026-06-23
Integrating Gemini 3.2 Pro Function Calling into iOS/Android Apps: Production Design Patterns
A practical guide to integrating Gemini 3.2 Pro Function Calling into iOS and Android apps. Includes working SwiftUI, Kotlin, and Python code, plus production patterns proven in a real indie wallpaper app — cost, latency, staged rollout, and regression testing.
API / SDK2026-05-25
Running In-App Help Translation on Gemini 2.5 Flash for Three Months — An Indie Developer's Notes
After three months running my iOS and Android in-app help through a Gemini 2.5 Flash translation pipeline, here are the operational notes — when to fall back to Pro, how glossaries help, and the small lift it added to AdMob revenue.
API / SDK2026-05-24
Two Weeks of Classifying Half a Year of App Store Reviews with Gemini File API
I ran half a year of App Store Connect reviews through the Gemini File API for two weeks straight, asking it to classify and summarize them. Here is what worked, where Batch Mode fit better, and which sharp edges took me a few days to round off.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →