GA4 Watcher: free prompt + code
GA4 Alerts That Catch a Broken Site
in 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.
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.
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.
| Date | Sessions | Bounce | Pageviews / session | What happened |
|---|---|---|---|---|
| Sep 18 | 69 | 78% | 1.23 | normal |
| Sep 19 | 83 | 77% | 1.12 | normal |
| Sep 20 | 111 | 43% | 6.86 | footer edit ships |
| Sep 21 | 79 | 14% | 12.18 | watcher would email here |
| Sep 22 | 183 | 13% | 10.63 | · |
| Sep 23 | 2,984 | 2% | 3.68 | Singapore traffic starts |
| Sep 24 | 19,072 | 1% | 2.89 | one browser, no referrer |
| Sep 30 | 112 | 12% | 19.30 | · |
| Oct 5 | 903 | 99% | 4.26 | bot returns, bounces on everything |
| Oct 6 | 44 | 93% | 12.11 | bug found and fixed |
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.
| Rule | Fires when | Why it exists |
|---|---|---|
| Sessions | 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. | Log scale makes up and down symmetric. The floor stops a site going 4 to 9 from paging you. |
| All-country sessions | Same test with no country filter, needs 50+ sessions on one side. | The rule that sees a bot flood the home-country view hides. Caught the Singapore rows above. |
| Bounce rate | Moves 25 points or more. Daily only on days with 30+ sessions, else 7-day ratio of sums. | A percent change on a percentage is meaningless. Thin days are binomial noise. |
| Pageviews / session | 2x either way, same sample-size guard as bounce. | The first metric a recursive iframe or double-firing tag breaks. |
| Zero sessions | 0 sessions on a property whose 90-day median is 5 or more. | A dead tag otherwise makes its own property look inactive and gets skipped. |
| Zero per key event | 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. | When a form breaks, phone clicks keep counting and the total never hits zero. Per name matters. |
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.
| GA4 custom insights | This watcher | |
|---|---|---|
| Scope | One property at a time, configured by hand, up to 50 per property | Every property the service account can read, no per-property setup |
| New client onboarded | Rebuild your insights on the new property | Add the service account as Viewer. Done. |
| Day two of a problem | The broken number becomes part of the comparison window | Baseline frozen at open, rechecked daily until two clean days |
| Tag dies completely | No data arrives, so there is nothing to compare | Zero sessions on an active property is its own rule |
| Form breaks, phone clicks keep firing | An alert on total key events never reaches zero | Zero rule runs per event name |
| What the email says | The metric and the change | The metric, the frozen baseline, top movers by page, host, country, source, and a paste-ready prompt |
| Sunday vs Wednesday | Depends on the condition you wrote | Same-weekday baseline over four weeks |
| Rules you can read | No | rules.py, 300 lines, 29 tests |
| Cost | Free | Free, plus a $5 coffee if you feel like it |
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
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
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
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
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:
Step 1
Open
First fire. The baseline is frozen at that moment and written to the state file. This is the email.
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.
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.
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:
| Variant | Change | Emails / week | Incidents |
|---|---|---|---|
| A | Baseline: 1.8x threshold, bounce 20 points, resolved sends its own email | 5.7 | 183 |
| B | Resolved incidents ride along with the next email | 4.0 | 183 |
| C | Threshold 1.8x to 2.0x | 5.0 | 142 |
| E | Plus a 30-session absolute floor on count rules | 2.6 | 109 |
| G | Bounce 20 to 25 points (shipped) | 2.4 | 94 |
| H | Threshold 2.5x, floor 40 (too blunt, not shipped) | 1.9 | 75 |
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
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
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
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
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
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 YouTubeInstant 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.
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?+
Does this replace GA4 custom insights?+
How is this different from GA4 anomaly detection on the Insights card?+
What do I need to run the GA4 watcher?+
How many GA4 alert emails will I get?+
Does it catch a tag that stops firing completely?+
Why does it read the day before yesterday?+
Does it work with one property, or only an agency roster?+
Can I send Google Analytics alerts to Slack instead of email?+
Is the code free, and do I need Claude Code?+
More Claude Code tools
Other guides from the same playbook.
Search Console
SEO Morning Report
Claude reads your GSC data every morning and hands you a ranked action list.
Setup
Connect Claude to Search Console
The same service-account pattern this watcher uses, step by step.
Guide
The Complete Claude Code Guide
CLAUDE.md, MCP servers, subagents and cost control, end to end.
Also useful: the Claude Code agent team pack (the blind-critic pattern above is the reviewer agent doing its job) and the full downloads library.

