↑

GA4 Watcher: free prompt + code

GA4 Alerts That Catch a Broken Sitein Two Days, Not Sixteen

A daily Google Analytics anomaly watcher that checks every GA4 property your service account can see and emails you only when something breaks or recovers. It runs on 49 properties at my agency. The prompt that builds it and the full Python code are free.

Watch the Build

Free: prompt, code, tests. If it saves you time, buy me a coffee (optional).

By Sep G, Zio Advertising. Runs ads and SEO for local businesses, which is how one service account ended up watching 49 GA4 properties. Last updated October 8, 2026.

Quick answer

GA4 alerts from this watcher fire when sessions double or halve against the same weekday, bounce moves 25 points, pageviews per session doubles, a tag goes to zero, or a key event records none when five were expected. One email per open or close. About two a week across 49 properties.

This is for you if

  • ✓You manage GA4 for more than one site and nobody checks them daily
  • ✓You have found out a site was broken because the client told you
  • ✓A form died once and leads went to zero while the dashboard looked fine
  • ✓You want the rules in code you can read, not a black box

Skip it if

  • ×You need alerts within the hour (this reads the day before yesterday on purpose)
  • ×You want a hosted dashboard with a login
  • ×Your site has under 5 sessions a day (nothing is statistically there to watch)

What Is a GA4 Anomaly Watcher?

A GA4 anomaly watcher is a script that pulls daily metrics from the Google Analytics Data API for every property it can read, compares each day against that property's own recent history, and sends a message when a number lands outside the expected band. That's the whole category. The differences are in what counts as "expected," what happens on day two of a problem, and whether the message helps you find the cause.

This one is built around a specific failure: a tracking bug that nobody notices because the dashboard still shows traffic. It is not a real-time tool. It is not a dashboard. It is a Python script, a state file, and a Gmail App Password, scheduled hourly, doing work once a day.

49

GA4 properties watched by one service account

2.4

email-days per week after tuning (was 5.7)

2 days

to an alert, instead of the 16 it took a human

29

unit tests on the pure rules and state machine

The Bug: One Backslash, Sixteen Days

On September 20 a Claude Code session edited a client's WordPress footer. My CLAUDE.md told it to escape quotes before saving, which is right for page content and wrong for a site setting. So every quote in the Google Maps embed got a backslash in front of it. The saved line looked like this:

<iframe src=\"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2570.5!2d-119.5833 ...

A browser reads src=\"https://... as a relative URL on the client's own domain. The map slot asked for a page that doesn't exist, got the site's 404 page, and the 404 page has a footer. With a map slot in it.

The site loading inside its own footer map slothomepage · footer · map slot ↓page_view #2 · 404 page · footer · map slot ↓page_view #3 · 404 page · footer · map slot ↓page_view #4 · 404 page · footer · map slot ↓page_view #5 · 404 page · footer · map slot ↓… 10 to 15 page views per visit
What one escaped quote does: the map iframe resolves to a relative URL on the site itself, so the slot loads the site's 404 page, whose footer has the same slot.

Here is what that did to the daily numbers. Sessions filtered to Canada, pulled from the Data API. The two Singapore rows are a separate oddity that started the same week and that I still can't fully explain: one browser, one OS, no referrer, 36,000 sessions over a few days.

Sep 18

Sessions
69
Bounce
78%
Pageviews / session
1.23
What happened
normal

Sep 19

Sessions
83
Bounce
77%
Pageviews / session
1.12
What happened
normal

Sep 20

Sessions
111
Bounce
43%
Pageviews / session
6.86
What happened
footer edit ships

Sep 21

Sessions
79
Bounce
14%
Pageviews / session
12.18
What happened
watcher would email here

Sep 22

Sessions
183
Bounce
13%
Pageviews / session
10.63

Sep 23

Sessions
2,984
Bounce
2%
Pageviews / session
3.68
What happened
Singapore traffic starts

Sep 24

Sessions
19,072
Bounce
1%
Pageviews / session
2.89
What happened
one browser, no referrer

Sep 30

Sessions
112
Bounce
12%
Pageviews / session
19.30

Oct 5

Sessions
903
Bounce
99%
Pageviews / session
4.26
What happened
bot returns, bounces on everything

Oct 6

Sessions
44
Bounce
93%
Pageviews / session
12.11
What happened
bug found and fixed

Pageviews per session went from about 1.2 to between 12 and 19. Bounce fell from the high 70s to the low teens. GA4's home-screen comparison card later rendered that as a bounce-rate change of several thousand percent. Nobody opened the card. The client didn't notice, because the site looked fine. I found it on October 6 while pulling data for an unrelated report.

GA4 Alerts: What the Watcher Fires On

Six rules, evaluated once a day on the day before yesterday, for every property with at least 28 days of history and a 90-day median of 5 or more sessions. Counts and ratios are compared against the same weekday over the prior four weeks, which is what stops a Sunday from looking like an outage.

Sessions

Fires when
2x up or down vs the same-weekday median, on a log scale, plus a floor of 30 sessions difference. Sites under 20/day compare trailing-7-day sums.
Why it exists
Log scale makes up and down symmetric. The floor stops a site going 4 to 9 from paging you.

All-country sessions

Fires when
Same test with no country filter, needs 50+ sessions on one side.
Why it exists
The rule that sees a bot flood the home-country view hides. Caught the Singapore rows above.

Bounce rate

Fires when
Moves 25 points or more. Daily only on days with 30+ sessions, else 7-day ratio of sums.
Why it exists
A percent change on a percentage is meaningless. Thin days are binomial noise.

Pageviews / session

Fires when
2x either way, same sample-size guard as bounce.
Why it exists
The first metric a recursive iframe or double-firing tag breaks.

Zero sessions

Fires when
0 sessions on a property whose 90-day median is 5 or more.
Why it exists
A dead tag otherwise makes its own property look inactive and gets skipped.

Zero per key event

Fires when
A named key event records 0 over a window sized so 5 or more were expected (3 to 14 days). Events rarer than 10 per 28 days are skipped.
Why it exists
When a form breaks, phone clicks keep counting and the total never hits zero. Per name matters.

One more: if 30% or more of your properties fire the same rule on the same day, you get a single "portfolio-wide" line instead of a wall of alerts. Holidays and GA4 processing delays look like that, and they are not your problem to fix.

GA4 Custom Insights vs This Watcher

To be fair to Google: GA4 does have custom insights with email notifications. You pick a metric, a condition (a percent change, a threshold, or "has anomaly"), and who gets the email. For one property with one person watching it, that is probably enough. Here is where the two part ways.

Scope

GA4 custom insights
One property at a time, configured by hand, up to 50 per property
This watcher
Every property the service account can read, no per-property setup

New client onboarded

GA4 custom insights
Rebuild your insights on the new property
This watcher
Add the service account as Viewer. Done.

Day two of a problem

GA4 custom insights
The broken number becomes part of the comparison window
This watcher
Baseline frozen at open, rechecked daily until two clean days

Tag dies completely

GA4 custom insights
No data arrives, so there is nothing to compare
This watcher
Zero sessions on an active property is its own rule

Form breaks, phone clicks keep firing

GA4 custom insights
An alert on total key events never reaches zero
This watcher
Zero rule runs per event name

What the email says

GA4 custom insights
The metric and the change
This watcher
The metric, the frozen baseline, top movers by page, host, country, source, and a paste-ready prompt

Sunday vs Wednesday

GA4 custom insights
Depends on the condition you wrote
This watcher
Same-weekday baseline over four weeks

Rules you can read

GA4 custom insights
No
This watcher
rules.py, 300 lines, 29 tests

Cost

GA4 custom insights
Free
This watcher
Free, plus a $5 coffee if you feel like it

Google Analytics notifications are fine at telling you a number moved. The gap is everything after that: which page, which host, whether it is still broken on day nine, and whether the one property that went completely silent is even being checked.

How the Alert Email Reads

This is the alert the watcher produces for September 21 when you replay the data, with the client's paths generalized. Everything else is verbatim.

=== 2026-09-21 ===
NEW  Client site: bounce 4% vs 60% (-56pp) [daily]
    top pagePath: /"https:/www.google.com/maps/embed 803 (was 0), /services/ 17 (was 31), /guides/ 9 (was 6)
    top hostName: client-site.ca 50 (was 66)
    top country: Canada 50 (was 66), United States 9 (was 17), China 9 (was 4)
    top sessionSourceMedium: (direct) / (none) 18 (was 5), fb / paid 15 (was 28), google / organic 12 (was 22)
    cd ~/clients/client-site && claude
    > GA4 watcher flagged Client site on 2026-09-21: bounce 4% vs 60% (-56pp). Before blaming
      traffic: break page_view down by pagePath, then check hostName, country and sessionSource
      for the mover. A junk pagePath (quoted URL, 'https:/' with one slash) means a broken
      src/href on the site. Report root cause, then fix.
NEW  Client site: pageviews/session 17.76 vs 1.38 (+1190%) [daily]
  1. 1

    The rule and the frozen baseline. Bounce 4% against a baseline of 60%, a 56-point drop, on the daily rule. That baseline number will not move until the incident closes.

  2. 2

    Top movers, pagePath first. The first line is a quoted URL with one slash after https. That is not a page on the site. It is a broken src attribute, and it accounts for 803 of the page views. The answer is in line one.

  3. 3

    hostName, country, source. A foreign hostName means a stale copy of the site is firing your tag. A new country means a bot. A source shift means a campaign. Here, all three are quiet, which rules those out.

  4. 4

    The paste-ready prompt. A cd line into the client folder and a prompt that tells the agent to break page_view down by pagePath before it blames traffic. Day two, the fix is a copy-paste away.

Why a Frozen Baseline Is the Whole Trick

My first design was the obvious one: flag anything 80% off its 7-day rolling average, dedupe so I don't get the same alert twice, email me. Before writing code I handed that design to a second Claude that could see only the document and told it to attack it. Then I did it again with the revised design.

Round one found

  • The baseline absorbs the bug. By day five, four of the seven baseline days are bug days. The median is the bug. One email, then the same sixteen days of silence.
  • A dead tag makes its own site ineligible. The API omits days with no data. After a week the 7-day median drops under the activity floor and the site is skipped for good.
  • Plus or minus 80% is wrong for rates and for drops. Bounce at 50% can't rise 80%. A site down by half is a 50% drop and never fires.

Round two found

  • Open incidents email forever. A client doubles their ad budget. Against a frozen baseline the metric never recovers, so it is "OPEN day 140" and you get a digest every morning.
  • The zero-leads rule misses the failure it exists for. When the form breaks, phone clicks keep firing. Total key events never hits zero.
  • Ratios break on small samples. A site with 6 sessions a day swings 20 bounce points from noise alone.

Round two also deleted a third of what round one had added. What survived is a small state machine. Every incident goes through it:

  1. Step 1

    Open

    First fire. The baseline is frozen at that moment and written to the state file. This is the email.

  2. Step 2

    Recheck

    Every day after, that day’s value is compared to the frozen number with the same comparator. Shown as OPEN day N. No email.

  3. Step 3

    Resolve

    Two consecutive in-range days close it as resolved. That rides along with the next email, it does not send its own.

  4. Step 4

    Re-baseline

    Day 14 still open? It closes as a permanent shift. The bigger ad budget is the new normal now.

Backtest Before You Schedule

The script has a --replay START END mode that runs every day in order through the state machine with a throwaway state, then prints email-days per week. Before I let launchd near it, I ran 180 days across all 49 properties. The first pass was unusable. Eight variants later, one constant at a time:

A

Change
Baseline: 1.8x threshold, bounce 20 points, resolved sends its own email
Emails / week
5.7
Incidents
183

B

Change
Resolved incidents ride along with the next email
Emails / week
4.0
Incidents
183

C

Change
Threshold 1.8x to 2.0x
Emails / week
5.0
Incidents
142

E

Change
Plus a 30-session absolute floor on count rules
Emails / week
2.6
Incidents
109

G

Change
Bounce 20 to 25 points (shipped)
Emails / week
2.4
Incidents
94

H

Change
Threshold 2.5x, floor 40 (too blunt, not shipped)
Emails / week
1.9
Incidents
75

Every variant had to pass two gates: flag the September 20 bug on its onset day, and flag the Singapore flood on the all-country rule. G was the quietest variant that still used a 2x bar, so G shipped. Honest caveat: the September bug was a must-pass test while tuning, so catching it is a pass, not independent proof.

How to Install the GA4 Watcher in 10 Minutes

Two routes to your first GA4 alerts. Paste the prompt into Claude Code and let it build, test and backtest the watcher on your machine, or unzip the reference code and run it as is. Either way these are the moving parts:

  1. 1

    Service account

    In Google Cloud, create a service account, enable the Google Analytics Data API and the Admin API, download the JSON key. Add the service account email as Viewer on each GA4 property, or on the account so new properties inherit it. This is the same setup my Connect Claude to Search Console guide uses.

  2. 2

    Gmail App Password

    Turn on 2-Step Verification, then create an App Password at myaccount.google.com. The watcher sends through smtp.gmail.com on port 465. It never touches your inbox.

  3. 3

    Secrets file

    Copy ga4-watch.env.example to ~/.ga4-watch/ga4-watch.env, chmod 600, fill in the address, the App Password and the key path. Gitignored by design.

  4. 4

    Dry run, then replay

    python watch.py --dry-run prints what today’s run would send without sending or saving. Then --replay over 180 days. If you are over 2.5 email-days a week, change one constant in rules.py and run it again.

  5. 5

    Schedule hourly

    macOS: edit the paths in the plist, copy it to ~/Library/LaunchAgents, launchctl load. Linux: an hourly cron line. The run only does work after 10:00 local and only for dates it has not processed, so a laptop that was closed catches up and sends one combined digest.

Optional but worth it: a free healthchecks.io check with a 26-hour grace period. The watcher pings it after every successful run, so if the laptop dies or the token expires, you get told. Silence is otherwise indistinguishable from "all clear."

Video walkthrough

Watch the bug, the fix, and the build.

The full walkthrough (the recursive footer, the comparison card, two blind critique rounds, and the 5.7 to 2.4 backtest) drops on YouTube. Subscribe to catch it.

Subscribe on YouTube

Instant access

Get the GA4 Watcher Prompt and Code

Everything I run, with my paths and keys taken out:

  • The 10-step build prompt for Claude Code or any coding agent, backtest gate included
  • Six Python modules: fetch, rules, incidents, report, mailer, watch
  • 29 unit tests for the pure functions
  • The launchd plist, the env template, the override-only properties.json
  • A README with the 10-minute install and the Gmail filter to create

Free: prompt, code, tests. If it saves you time, buy me a coffee (optional).

Three Things to Copy Even If You Never Run It

If you run client analytics

Break page_view down by pagePath before you blame traffic. Then hostName, then country. A junk path means a broken attribute on the site. A foreign host means a stale copy firing your tag. Ninety seconds, and it beats every dashboard card.

If an agent edits client sites

Your CLAUDE.md is production code. When you fix a bug the agent caused, fix the instruction that caused it, in the same commit. Mine now says which contexts get escaped and which do not.

If you build tools with Claude Code

Hand the design to a second, blind Claude before any code, and do it twice. Then backtest before you schedule. The first pass of this tool would have sent 5.7 emails a week. Nobody keeps reading at 5.7.

Bonus: when the fix lands, the site's bounce rate "jumps" back up. That is the fake number leaving. GA4 alerts from the watcher file it as RESOLVED, not as new.

What else is hiding?

AI Workflow Audit ($750)

The bug that started this was one line in a CLAUDE.md. The agent did exactly what it was told. The audit is me going through your Claude Code setup and your analytics configuration, reading your instructions for lines like that one, and sending you the fix list: CLAUDE.md rewrite, MCP audit, tracking checks, prioritized actions, delivered in a 75-minute Google Meet with copy-paste configs.

If a sixteen-day blind spot makes you wonder what else you are not seeing, this is the fastest way to find out.

Book Your AI Workflow Audit ($750)

75-minute Google Meet. Copy-paste deliverables. No upsells.

Guarantee: if the audit does not surface at least 5 actionable improvements, full refund. Same day.

FAQ

GA4 alerts, answered

What are GA4 alerts, and which kind does this watcher send?+
GA4 alerts are notifications that a metric in a Google Analytics 4 property moved outside its normal range. GA4 sends its own through custom insights. This watcher sends a different kind: one email when an incident opens or resolves on any property your service account can read, with the top moving pages, hostnames, countries and sources included.
Does this replace GA4 custom insights?+
It does the job custom insights were meant to do, across every property at once. Custom insights are set up by hand, one property at a time, and the email tells you a number moved. This watcher freezes the baseline when something breaks so it keeps reporting until it is fixed, checks each key event by name so a dead form shows as zero even while phone clicks keep counting, and tells you which page moved. You can keep custom insights running next to it.
How is this different from GA4 anomaly detection on the Insights card?+
GA4 anomaly detection is a model Google runs on your data and surfaces as cards on the Home and Insights screens. It is read-only, per property, and it does not email you unless you also build a custom insight with the "has anomaly" condition. This watcher is deterministic (you can read every rule in rules.py), runs across all properties, and the email is the product.
What do I need to run the GA4 watcher?+
Python 3.11 or newer, a Google Cloud service account JSON key with the Google Analytics Data API and Admin API enabled (added as Viewer on your GA4 properties), a Gmail App Password for sending, and a machine that is on most of the day: a Mac with launchd, or any Linux box with cron. An optional free healthchecks.io check emails you if the job stops running.
How many GA4 alert emails will I get?+
Backtested over 180 days on 49 properties it averaged 2.4 email-days per week. The first untuned pass was 5.7. It only emails when an incident opens or resolves, never every day an incident stays open, and a Monday heartbeat proves the pipeline is alive. Sites with under 20 sessions a day are compared week over week instead of day over day so they do not create noise.
Does it catch a tag that stops firing completely?+
Yes. A property whose 90-day median is at least 5 sessions a day and which records 0 sessions on the target date fires the zero-sessions rule. Most anomaly tools skip a property with no data, which is exactly the property with the dead tag.
Why does it read the day before yesterday?+
GA4 can still be reprocessing yesterday for up to 48 hours. Reading D-2 after 10:00 local time means the numbers it compares are final, so you do not get an alert at 9am that reverses itself at noon.
Does it work with one property, or only an agency roster?+
One property is fine. Eligibility is per property: 28 days of history and a 90-day median of 5 or more sessions a day. The portfolio collapse rule (30% of properties firing the same thing becomes one line) only matters once you have a few.
Can I send Google Analytics alerts to Slack instead of email?+
The reference code sends through Gmail SMTP because that needed zero new accounts. mailer.py is 40 lines and the digest is plain text, so swapping in a Slack incoming webhook is a one-function change. The prompt tells the agent to keep the mailer isolated for exactly this reason.
Is the code free, and do I need Claude Code?+
The prompt, the six Python modules, the 29 tests, the launchd plist and the env template are free. The prompt is written for Claude Code or any coding agent, and it walks the agent through building and backtesting the tool on your machine. At runtime it is plain Python with no AI involved. One attribution line in your README is the only ask. If it saves you a sixteen-day blind spot, a $5 coffee is appreciated and optional.
Zio team member

Got a quick question?

Sep usually replies within a few hours

Or email us at [email protected]