Marketing

Anomaly Detection for Marketing Dashboards: Gemini + Slack Alerts on CAC Spikes

Anomaly Detection for Marketing Dashboards: Gemini + Slack Alerts on CAC Spikes
Contents

At 4:11 AM on a Tuesday in March, my Slack went off. CAC (Customer Acquisition Cost, 获客成本) for the Meta channel was up 47% versus the 14-day baseline, and Gemini named the cause in the message body: a single ad set whose CPM (Cost Per Mille, 千次曝光成本) jumped 4x because Meta's "auto-expand audience" feature quietly broadened targeting overnight. The campaign manager was paged at 8 AM, paused the ad set, CAC normalized by 11 AM. Total wasted spend: about $2,400. The week before, with no alert, the same failure mode would have run for 96 hours and cost $9,600.

That's the whole point of AI-driven anomaly detection. Threshold alerts — "if CPA > $50" — miss this because $47 looks fine on paper. AI catches it because it can compare today to a 14-day baseline, check standard deviation, and (this is the part thresholds cannot do) name a plausible cause. Naming the cause is what makes the alert actionable instead of ignorable.

Why threshold-based alerts fail

Three failure modes, all from real campaigns:

  1. Static thresholds ignore seasonality. Black Friday CPA is supposed to be 60% higher. A $50 alert fires every November and gets muted by December.
  2. Thresholds don't have context. A $42 CPA is great for a SaaS trial and terrible for an e-commerce checkout. Same number, opposite meaning.
  3. Thresholds never name the cause. "CPA > $50" is a fact. "CPA is up 47% because one ad set's CPM 4x'd from audience expansion" is a decision. Slack gets the second one read; the first one gets snoozed.

AI anomaly detection does not replace thresholds. It adds a second layer: statistical comparison against a baseline, plus a name-the-cause prompt that turns a number into a story.

The data setup (90 days, daily, three sources)

Pull 90 days of daily KPIs into a single Google Sheet tab. One row per day, one column per metric. Minimum columns:

date | channel | spend | impressions | clicks | conversions | revenue | cpm | ctr | cpa | roas

Pull from:

  • GA4 — via the GA4 Data API or Supermetrics, sessions, conversions, source/medium
  • Meta Ads — via the Marketing API, daily spend and per-ad-set breakdown (this is the granular level you need; channel-only is too coarse to name a cause)
  • Google Ads — same, daily + campaign level

Schedule the pull nightly at 23:55. The "today" row stays a partial day until tomorrow's pull, which is fine — your baseline window is the prior 14 complete days, and the comparison only runs after the day closes.

The Gemini prompt (the part that does the actual work)

This is the prompt I run nightly. It feeds Gemini the last 14 days for each channel as a baseline, the last 3 days as the "current" window, and asks three things: is it anomalous, by how much, and what could plausibly cause it.

You are a marketing analytics assistant. Today is {TODAY_DATE}.

Below is the last 14 days of daily CAC values for each channel, with the most
recent 3 days marked as [CANDIDATE].

For each channel:
1. Compute mean and standard deviation over the 14-day baseline.
2. Compute the day-over-day change for the most recent [CANDIDATE] day.
3. Flag as ANOMALOUS if:
   - the candidate value is more than 2 standard deviations from the
     baseline mean, OR
   - the day-over-day change is more than ±30%.
4. If ANOMALOUS, return:
   - severity: red (>=2.5σ OR >=50% DoD) or yellow (2-2.5σ OR 30-50% DoD)
   - metric and value
   - delta vs baseline (%)
   - one-sentence plausible cause, drawn from: channel mix shift,
     audience saturation, creative fatigue, tracking/pixel break,
     bid strategy change, auction pressure, audience auto-expansion
   - one-sentence remediation suggestion

Output strictly as JSON. Do not summarize. Do not editorialize.

DATA:
{CHANNEL_DATA_JSON}

Three things in the prompt that matter:

  • "from" — listing the cause menu stops Gemini from inventing causes like "viral tweet" or "lemonade season." Constrain the hypothesis space.
  • "remediation suggestion" — this is the part that keeps alerts from being muted. The "no remediation, no alert" rule is below.
  • "strictly as JSON" — without this, Gemini sometimes returns a friendly email. You want parseable output for the Slack node.

The n8n workflow (one cron, three nodes, one Slack)

json{
  "nodes": [
    {
      "name": "Nightly Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "hour": 0, "minute": 5 } }
    },
    {
      "name": "Read Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "parameters": {
        "operation": "read",
        "sheetId": "YOUR_SHEET_ID",
        "range": "A1:K2000"
      }
    },
    {
      "name": "Detect Anomalies",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "method": "POST",
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
        "headers": { "x-goog-api-key": "={{$env.GEMINI_API_KEY}}" },
        "body": {
          "contents": [{
            "parts": [{
              "text": "={{$json.prompt}}"
            }]
          }]
        }
      }
    },
    {
      "name": "Parse + Filter",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const out = JSON.parse($input.first().json.candidates[0].content.parts[0].text); return out.alerts.filter(a => a.severity === 'red' || a.severity === 'yellow');"
      }
    },
    {
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#marketing-alerts",
        "text": "={{$json.severity.toUpperCase()}} — {{$json.channel}} {{$json.metric}} {{$json.delta}}%\nCause: {{$json.cause}}\nFix: {{$json.remediation}}"
      }
    }
  ]
}

The Parse + Filter node is the unsung hero. Without it, you ship a daily "no anomalies found" message to Slack. With it, the workflow stays silent on quiet days and only pings the channel when there is something a human should look at. Silent days are the point.

The hard rule: no remediation, no alert

This is the part that decides whether the system survives month three or gets muted. Every alert must include a one-sentence remediation suggestion. If Gemini can't produce one with confidence, the alert does not fire — it goes to a daily digest instead.

The math is simple: an alert that says "Meta CAC up 47%" teaches the team to ignore the channel. An alert that says "Meta CAC up 47% — single ad set X's CPM 4x'd, probably audience auto-expansion, pause the ad set and review Advantage+ audience controls" is a different object. It is a decision the team can act on in 30 seconds, so they do. The first 30 days of running this, the team acted on 11 of 14 alerts. A threshold system on the same data fired 47 times and was acted on 4 times. Severity, naming the cause, and remediation are the only reasons the AI version got opened.

The other half of the rule: alerts without action are a smell test failure. If a real anomaly fires and the only remediation is "investigate," the data layer is wrong, not the alert. Fix the data layer.

What this catches that dashboards miss

The 4 AM Meta CPM spike was the easy case. The system has also caught, in the last six months:

  • A Google Ads conversion tracking break on a Friday afternoon that would have shown up as a "great weekend" until Monday's report
  • An iOS attribution shift that cost 22% of Meta reported conversions overnight
  • A landing page change that quietly tripled CPA for paid social
  • A competitor entering the auction on a single keyword cluster, visible only as a 2.3σ CPM move on one campaign

None of these had a "CPA > $50" signature. They were all caught by the combination of statistical baseline + named cause. That combination is the entire system.