I own about ten domains. I want two of them to grow: DailySpin, a daily music blog, and Southbound, the marketing site for my iOS app. The rest are personal and family domains. They mostly exist so that nobody else has them.

All ten sit behind Cloudflare. So all ten generated analytics this whole time: traffic, threats, bot activity, and referrers. I looked at almost none of it.

The reason was not apathy. A look requires that you remember to look, and a dashboard is a place that you must go to. A dashboard is the gym membership of data. Email arrives whether you remember or not. So I built the thing that goes to the dashboard for me and writes back in sentences.

The teaching mandate

The one new idea here started as an admission: I was new to web analytics. I could read “bounce rate” and not know whether mine was bad.

That single fact changed the whole product. The system prompt opens with it:

Ryan is new to web analytics, so explain concepts naturally when they come up — don’t assume he knows jargon like “bounce rate” or “UTM parameters” without briefly explaining what they mean and why they matter.

An analytics tool that reports to an expert produces a dashboard. An analytics tool that teaches produces a different thing. It defines each term at its first use. Then it tells you whether your number is good. Six months later, I do not need the definitions. That means the tool worked.

The rest of the prompt is mostly anti-padding rules. Most of the edit time went there:

Only mention zones with noteworthy activity. “All quiet across all 10 sites” is a perfectly valid report. Don’t waste Ryan’s time with normal traffic patterns.

And for recommendations, this rule stops the consultant mush from the model:

Be specific (“write more posts about 90s alt-rock artists” not “create more engaging content”).

Each section is permitted to be one line. A quiet day must produce a short email. If you do not say that explicitly, you get four paragraphs about nothing each morning. Within two weeks, you stop opening the email.

I measured the wrong number

This finding changed the product.

The first version pulled everything from Cloudflare’s GraphQL analytics API: requests, page views, threats, and country breakdowns. That is real data, straight from the edge. And the numbers meant nothing.

Cloudflare’s edge sees requests. Requests include bots, crawlers, uptime monitors, favicon fetches, and CSS files. Somewhere in there are the humans. One person on a page with a stylesheet, three images, and a font makes six requests. Then the AI crawlers arrive on top. My “traffic” was mostly machines that fetched assets. My most loyal reader was an uptime monitor.

The fix had two parts. I added a real analytics tool that counts humans. Then I taught the prompt that the two data sources are not the same species. A ground-truth clause now sits at the top:

Umami = humans, CF edge = bots + crawlers + assets — never conflate. If Umami says 3 humans and CF edge says 196 requests, that’s normal.

Those numbers are real, and they are mine. Three humans. One hundred ninety-six requests. If I kept my focus on the big number, I would spend months with good feelings about a website that nobody read.

The current version reports in tiers. Two sites get deep human analytics. Two more sites get a numbers-only glance. The edge data survives as an appendix with an explicit label: bots and assets, not readership. Two toy projects left the email completely. A site with no goal does not need a daily report.

Tell the model what shipped

A subtler failure: the prompt described Southbound as a site with a waitlist. Then the app launched, and the waitlist went away. The model reasoned perfectly from its instructions. It continued to recommend improvements to a signup form that no longer existed.

So the prompt grew a product-state block, stated as ground truth. The app is live. There is no waitlist. The funnel is page view → store click → install. Never recommend a waitlist form. Facts about your own product drift faster than anything else in the prompt, and the model cannot notice the drift. It continues to give you excellent advice about your previous product.

When a workflow tool wins, and when it loses

I built this in n8n, a visual workflow tool. I also have a sibling system, my personal morning brief, with a similar structure in hand-written code on a Cloudflare Worker. The shape is the same: gather data, give it to a model, render an email. The tool is different. Both choices were correct, and that is the interesting part.

n8n wins when the job is glue. This workflow is eight nodes: schedule, build queries, fetch edge data, fetch human analytics, build the prompt, call the model, format, send. Most of the value sits in credential storage, retries, and the UI. I can click a node and see the exact JSON that came back at 5 AM. For a data-shape problem, the visual runner is truly faster than log statements and redeploys.

Code wins when the logic is the product. The personal brief has fourteen data sources, per-source staleness rules, trend computation across a rolling window, and a schema-constrained model call. That is a program. A program wants version control, code review, and tests. Boxes and arrows would only decorate it.

My dividing line for someone else: if the hard part is to reach the services, use the workflow tool. If the hard part is what you do with the data after you have it, write code.

Then there is the deployment story. That story makes me think twice.

The workflow that ran old code for two days

n8n compiles a workflow into memory and uses a version identifier as its cache key. A workflow import through the CLI updates the stored definition. It does not change that version identifier. So the scheduler runs the old compiled code, through restarts, without an end.

Two consecutive morning sends ran stale code. The database plainly showed the new code. Nothing errored. The email arrived on time and looked correct. A version that I thought I replaced generated it.

It got worse before it got better. On a later version, direct edits to the stored definition did not reach the runtime at all. Schedule triggers silently never fired. Webhooks never registered. The boot log printed “Activated workflow” the whole time. The edit was even visible in the editor UI. Only the part that runs ignored it.

I burned about three hours across two sessions. I watched a workflow not fire at times that I scheduled with care.

The only reliable deploy was an app-layer save. You go through the application’s own API. Then the server changes the version itself and registers the schedule again. Look for the log line about deregistered crons. That line means a recompile.

That is the real price of a visual workflow tool: the state that matters lives in a database, not in your files. “I changed it” and “it runs” are two separate claims. Only one of them is easy to check. A command that lists active workflows will lie to you after an import cycle.

The other silent failures

The pattern continues down each layer:

  • fetch() inside a workflow code node silently does nothing. It does not fail. It just does not happen. I found this because a workflow ran perfectly and accomplished nothing. You must use the runner’s own HTTP helper.
  • Access to environment variables is blocked in those same sandboxed nodes. Also silently.
  • If you stringify an object for a request body, you encode it twice. The receiving database wrote rows with each field null and returned success.
  • A daily sync workflow broke because a Python container resolved localhost to IPv6 first. The IPv6 path of the target returned server errors on exactly one kind of query. The same code worked from the host. The workflow failed quietly for a while before anyone noticed.

Each one of these failures looked like success.

The duplicate that hid for ten days

This one is my favorite, because it is so stupid.

When I rebuilt the workflow with human analytics, I ran the new version next to the old one for comparison. Both sent to the same address, from the same address, with the same subject line format.

Gmail threaded them as one conversation. For ten days, I got two analytics emails each morning and saw what looked like one. The client collapsed the duplicate out of view, to be tidy. I caught it when a number did not match itself.

If you run two versions of anything in parallel, make them look different. Your email client optimizes for tidiness. It does not optimize for your ability to notice that you built the same thing twice.

What it still does not do

It does not close the loop. It gives me one specific recommendation per site per day. Nothing tracks whether I acted on the advice, or whether the advice worked. The correct version keeps a rolling memory of its own advice and grades itself the next week. I built that pattern elsewhere. I did not build it here.

It also cannot see past the click. Store clicks are the end of my visibility. The App Store is a different system with a different login. I do not check that one either.

Now that I write this down, it sounds like a job for tomorrow morning’s email.