[CODE]

Building GA4 Reports for People Who Don't Open GA4

Four B&B operators have GA4 running correctly and never look at it. A multi-source pipeline pulls GA4, Search Console, and Ads data every Monday, runs it through a three-stage LLM pipeline, and emails a plain-Chinese report — no login required.

6 min read AI-generated
ga4 claude typescript automation

The direct-booking thesis for small B&B operators is real but incomplete. OTA commissions run far higher than most people realize — eliminating even a fraction of that dependency through an owned booking channel meaningfully changes unit economics. But direct booking only works if the operator knows which campaigns drove contact, which pages converted, and whether a traffic drop this week is a seasonal pattern or a campaign problem. That requires weekly analysis. It doesn’t happen without a system to deliver it.

Four properties in Hengchun. All four had GA4 conversion tracking running correctly — phone taps, LINE button clicks, booking form submissions flowing into GA4, feeding Google Ads Smart Bidding. None of the operators were looking at any of it. Budget decisions still ran on gut feel: how busy the last holiday weekend felt, whether a cousin called to say tourists were in town.

This isn’t a usability problem. GA4 requires active effort: navigate to the right report, remember what you’re looking for, interpret what you find, repeat weekly. For an owner managing breakfast service and room turnover simultaneously, that sequence requires external prompting or it simply doesn’t happen.

Push beats pull

The system: a GitHub Action that fires every Monday at 09:37 Taipei time, runs 26+ parallel GA4 queries across all four sites, pulls Google Search Console impressions and click data, adds Google Ads campaign spend and CPC figures via GA4 linkage, then passes the combined dataset through a three-stage pipeline. Output: an HTML report emailed directly to each operator, and a Markdown version committed to the repository as an audit trail. No login required on either end.

The three-stage separation was deliberate. Stage A (fetch) calls APIs, normalizes responses, and writes structured JSON. No reasoning. Stage B (analyzer) reads that JSON and calls an LLM with a Zod-enforced schema, producing a validated insights array — structured claims with confidence levels, supporting number citations with source paths, and actionability flags. Stage C (render) generates Chinese prose from the validated insights.

When a report says something wrong, there are three distinct questions: did the data come back correctly? Did the model reason correctly from the data? Did the prose accurately reflect the reasoning? This architecture gives each question a distinct artifact to check. Without the separation, a wrong report is just a wrong report.

GA4 measurement IDs are public

The GA4 measurement ID ships in every page’s source code. Anyone can send hits to a property from any hostname — localhost dev tests, Railway preview deploys, or deliberate spoofing. Without a hostname filter, every GA4 query includes this noise. Conversion events fired during localhost testing pollute the primary KPI; preview deploy traffic inflates session counts.

The standard defense is ANDing the canonical domain into every GA4 query. The implementation is eight lines in the query builder.

This is the kind of error that compounds silently. Reports look plausible. Numbers are directionally correct but slightly inflated in ways that don’t trigger obvious alarms. Finding the issue requires knowing to look for it.

Arithmetic doesn’t belong in the LLM

All week-over-week, month-over-month, and year-over-year deltas are computed in Stage A before anything reaches the model. The LLM receives { current: 142, wowDelta: -18 } rather than two raw numbers.

This isn’t primarily about token efficiency. It’s about controlling the model’s reasoning surface. Given pre-computed deltas, the model reasons about the change. Given raw numbers, it constructs the comparison first, then interprets it — two steps that can fail independently and whose errors compound. Pre-computation also isolates failure modes: when a report says something wrong about a trend, arithmetic isn’t one of the suspects.

Sample-size enforcement belongs in the data, not the prompt

The threshold rule — fewer than 10 conversion events this week means don’t draw conclusions — is easy to put in a system prompt. It’s also easy for the model to override when a small-n data point arrives with a confident-looking trend in the context window.

Stage A stamps "sample_status": "low_sample" directly onto every data slice below threshold before the JSON is written. The model sees a structured field indicating a data quality constraint alongside the number itself. Mechanical enforcement rather than a rule that competes with data for the model’s attention.

The context problem, and the Lunar New Year failure

Early LLM outputs were accurate and useless: “Traffic declined 12% week-over-week.” One report confidently attributed a Lunar New Year organic search dip to weakening SEO. Organic search across virtually every Taiwanese website drops during Lunar New Year — it’s a national behavior pattern, not a content quality signal. The model didn’t have that context and reached for the most plausible business explanation from what it had.

The system prompt now has three layers. Engine-rules.md covers universal LLM behavior: anti-hallucination constraints, sample-size thresholds, no-jargon rules. Lodging-interpretation.md covers domain knowledge applicable to any accommodation property — how to interpret OTA referral traffic, why a downstream room-details page with high engagement rate isn’t the conversion win it looks like, how Smart Bidding’s observation window means campaign changes don’t show results for 11 days. Business-context.md is site-specific: which conversion events this property tracks, which ones signal serious inquiry versus casual browsing, which months are shoulder season, what the Google Ads account structure looks like.

The lodging-interpretation layer exists because four sites shouldn’t each maintain independent copies of the same domain knowledge drifting out of sync. Engine rules update rarely. Lodging interpretation updates when any site produces a misread that reveals a shared blind spot — and that learning propagates to all four sites immediately. Business context updates when an operator changes something about how their property operates.

Marginal cost: one config file

Onboarding a new site: copy an existing configuration folder, update the business context (property ID, conversion event list, KPI priorities), add credentials. Four sites currently. The engine doesn’t change.

The economics of this matter more than the engineering neatness. An analysis service where each new site carries meaningful engineering overhead doesn’t work at the client scale these operators represent — properties in a tourist town weighing a monthly Google Ads budget against OTA commissions far higher than most people realize. The LLM analysis layer is what makes the service viable at all. Without it, this isn’t a cheaper version of an analytics service. It doesn’t exist.

The booking tracker integration — which cross-references confirmed bookings from Supabase against GA4 click proxies — is live on one site. The other three still receive click signals only. The LLM occasionally hedges where a directional read would be more useful. Reports aren’t perfect. But the feedback loop that was missing is now closing every Monday morning, in Chinese, without anyone logging in to anything.

Getting an operator to act on the first report that flags a problem they can actually fix takes longer than building the system that generates it.