Alerts
A dashboard answers how is the site doing when somebody thinks to ask.
craft watch answers the question nobody is awake to ask: did something break.
It compares the most recent complete day against the mean of the days before it and reports what
moved further than it usually does.
There is nothing to configure for it to be useful. A site's own history is the threshold.
01Three things fire
A drop or a spike past the metric's threshold, and silence — a count that went to nothing against a baseline that was not nothing.
Silence is its own alert rather than a hundred-percent drop, because the number is the same but what it means is not. A bad week and a tag deleted in a deploy produce the same figure and call for completely different mornings. Silence is the one alert here that is almost never a false positive: sites have quiet days, they do not have days with literally no users.
If nothing came back anywhere in the window, that is reported once, as itself — a property that is not collecting, or an id that is not the one the site reports to. Six metrics all announcing their own silence would be six ways of saying that once.
02Thresholds that differ by metric
One number for all six either shouts about conversions, which swing by a third on an ordinary Tuesday, or never notices bounce rate, which barely moves. So the defaults are per metric:
| Metric | Fires past | Config key |
|---|---|---|
| users | 30% | users |
| sessions | 30% | sessions |
| page views | 30% | views |
| conversions | 40% | conversions |
| avg. session | 25% | avg_session |
| bounce rate | 20% | bounce_rate |
A count whose baseline is under 10 does not fire at all. On a site averaging four conversions a day, one quiet day is a twenty-five percent “drop” that means nothing, and an alert that cries wolf every morning is an alert people learn to close.
03Run it
# check once, print what moved, exit $ craft watch # keep checking, hourly $ craft watch --every 3600 # synthetic alerts — no account, no subscription $ craft watch --demo
One-shot is the shape for cron; --every is the shape for a service manager. Both survive a
bad network: in the loop, a failed check is reported and slept off rather than fatal. A watch that exits
on the first dropped connection is a watch that was not running when the thing it was watching for
happened.
04Send it to Slack
One command. Slack's own install screen carries the workspace and channel pickers, so the channel is chosen where you would expect to choose it and nothing is copied by hand.
$ craft slack --install # opens Slack; pick a channel there $ craft slack --test # post one message, to be sure $ craft watch # alerts go there now
It asks for one permission, and the narrowest one that works: to post to the single channel
you pick. Not chat:write, which would be permission to post anywhere in the workspace.
anacraft cannot read your messages, see your other channels, or post anywhere else.
No secret is embedded in the binary to make this work. Slack's PKCE support marks the app a public client, which drops the client secret from the code exchange — the only reason an OAuth install can live in a program anybody can download.
The webhook Slack hands back is a credential: anyone holding it can post to that channel. So it
lands in ~/.anacraft/slack.json at 0600, beside the OAuth token, and
deliberately not in config.toml — that file is meant to be safe to commit to a
dotfile repo, and this URL is not.
Or bring your own webhook
Some workspaces do not let members install apps, and cron and CI are happier with a URL in the
environment. --webhook takes an incoming webhook
you made yourself, and always works:
# a daemon posts for itself — it has nothing to pipe into $ craft watch --every 3600 --webhook "$SLACK_WEBHOOK" # or let cron do the delivery 0 9 * * * craft watch --format slack \ | curl -sX POST -d @- "$SLACK_WEBHOOK"
--format slack prints nothing at all on a quiet day, which is what stops a cron
line posting an empty message every morning. ANACRAFT_WEBHOOK works in place of the flag,
and --format chooses what the webhook receives — so a URL pointed at something other than
Slack gets a shape it can read. A hooks.slack.com URL is the exception and always gets
blocks, because Slack answers a bare JSON object with 400 no_text.
The destination is resolved in that order: the flag, then the environment, then whatever
craft slack --install saved.
05Tune it per property
# ~/.config/anacraft/config.toml [[property]] id = "397412345" [property.watch] baseline_days = 28 # days the baseline averages over min_baseline = 10 # a baseline under this never fires a count users = 25 # % deviation that wakes somebody conversions = 40 bounce_rate = 15
Keys take the short names in the table above, or the GA4 API name if you prefer it. Everything is
optional and falls back to the default, so a property that says nothing still gets useful alerts.
--baseline <days> overrides the window for a single run.
Twenty-eight days is the default because four weeks covers every weekday the same number of times. A site that is quiet at weekends is then not permanently half a standard deviation from its own normal.
06It only tells you once
The same day's alert is sent once. State is keyed by the day being reported on, so
--every 3600 sends one message about a drop rather than twenty-four, and tomorrow is news
again.
An alert is recorded as sent only after delivery succeeds. A webhook that was unreachable has told nobody anything, so the next pass tries again instead of treating the alert as spent.
07Numbers you can trust the date of
Two things here are less obvious than they look, and both are about not guessing.
The day being checked is GA's, never your laptop's. yesterday resolves in the
property's reporting time zone, so a window derived from the local clock is silently a day out for
anyone whose property sits elsewhere.
And the baseline's divisor depends on the metric. For a count, a day Google returned no row for is a day the site had none, and it belongs in the divisor — a site that went dark for a week has a genuinely lower daily average. For a rate or an average it does not: a day with no sessions has no bounce rate, and averaging it in as zero would pull the baseline toward a number the site never had.
08Scripting it
| Exit | Means |
|---|---|
| 0 | Checked, nothing fired |
| 2 | Something fired |
| 1 | The check itself failed |
--format json prints one object carrying the property, the day, each alert's value,
baseline, deviation and the threshold it crossed — the same shape the MCP server answers in, so a
script and an assistant read the same numbers.
09Look at it first
craft watch --demo runs on synthetic data: no Google account, no subscription, and three
alerts every time, so you can see what a drop, a silence and a spike look like — in your terminal or in
your Slack — before wiring anything up.
$ craft watch --demo --webhook "$SLACK_WEBHOOK"
The watch itself is part of the Anacrafter subscription, alongside craft mcp. The dashboard and the one-shot reports stay free.