Five B&B sites in Hengchun share one ads optimization pipeline. The pipeline’s optimization loop runs on GA4 events — specifically click proxies, the closest available signal to a confirmed booking in this market. None of these operators use a reservation system that fires a conversion event on confirmation. What they have is phone clicks, LINE taps, email clicks, and contact-page visits. Click proxies carry noise — a phone tap might be someone confirming operating hours rather than initiating a booking — but at a few hundred monthly sessions per property, click proxy volume accumulates into statistically useful signal within weeks. Waiting for actual booking confirmations to reach the same sample size would take years. The loop needs something to optimize toward, and click proxies are what exists at this scale.
For four of the five properties, the loop had signal. For one, the GA4 property had been empty since site launch: site live, ads account running, optimization engine reading zero. Not misconfigured — simply never instrumented. Getting it properly wired required more deliberate architecture than a single script tag produces, because the constraints compound: the implementation needs to survive continuously evolving event requirements, never contaminate staging data with developer traffic, and replicate to two remaining properties without producing codebases that drift independently.
Two components because the two halves break differently
The standard GA4 setup is one <script> block in <head>. I split it into two Astro components because the two halves have fundamentally different operational risk profiles.
GtagHead.astro is the bootstrap and lives in <head>: load gtag.js via the Google Ads tag ID (Google’s recommendation when Ads is active, so the Ads destination initializes before the first pageview), then register both destinations — GA4 and Google Ads — via separate gtag('config') calls. Its one requirement is to run correctly on every page, every request, indefinitely. Once deployed, it should go untouched for months.
Ga4Tracking.astro carries all event logic and lives at the end of <body>, after content has rendered. Placing it there is not arbitrary — the script runs queries against the DOM, and a body-end position guarantees the elements exist. It tracks five signals: phone_click, line_click, email_click, booking_click, and scroll_depth. This half changes continuously: an event added when behavioral analysis reveals a new signal worth tracking, a threshold adjusted when initial estimates prove wrong, a parameter removed when it contributes noise rather than signal.
If the two halves share a file, every iteration on event logic touches the must-never-fail bootstrap. Separate files means the bootstrap has a stable risk surface and the event layer has room to iterate freely. The blast radius of a regression in event tracking stays bounded to event tracking.
The scroll depth implementation carries a non-obvious fix. The site’s global CSS sets body { overflow-x: hidden }, which makes the body element the scroll container — window.scrollY stays at zero regardless of how far the visitor has scrolled. The depth handler reads defensively from both document.documentElement.scrollTop and document.body.scrollTop, and registers listeners on both window and document to catch the scroll event regardless of which target fires it. The initial implementation without this saw zero scroll events past the fold.
booking_click sends to GA4 but is explicitly excluded from Google Ads conversions. Visiting the contact page is a funnel step, not a contact action. Routing it into Ads as a conversion would optimize toward page navigation rather than visitors who actually initiated contact. The default instinct is that more conversion signals is better; it isn’t when the signal doesn’t map to the outcome being targeted.
One rule is codified in the codebase: every clickable contact point — phone, LINE, email, any CTA in the sticky bar or booking flow — must have a corresponding event handler in the same change that introduces the element. An untracked CTA is treated as a bug. The reason is diagnostic: a silent funnel gap and a genuinely low-converting channel produce identical symptoms in the data. “LINE inquiries are low this month” looks the same whether LINE contacts are low or LINE is simply not tracked. Tracking completeness has to be enforced structurally, not trusted to memory.
The measurement ID belongs to the deployment, not the component
Hardcoding the measurement ID directly in the head snippet works for a single site on one environment. It fails predictably at two points.
Staging: every test session flows into the production GA4 property, contaminating session counts, source attribution, and behavioral data with developer traffic. No flag to flip for isolation — the options are tolerating contaminated production data or abandoning staging deployments. Neither is acceptable for a client-facing system where data quality is the product.
Replication: two properties are queued for GA4. Copy-and-customize means two codebases that start identical and diverge as changes to one side get missed on the other. With three operators in the pipeline simultaneously, that maintenance debt compounds with every deployment cycle.
src/config/analytics.ts is the single source of truth: GA4 measurement ID, Google Ads conversion ID, and per-channel conversion labels all in one file. Both components import from it. Across all three operators, the component files are identical — what differs between deployments is the config. Per-client customization stays bounded to config changes, and replication is a matter of populating a new config file with the correct IDs.
GA4 is plumbing; the weekly report is the product
The owner won’t open GA4. That’s not a gap in the design — it’s the constraint the design works around.
The data destination is an automated weekly report: the source that drove the most booking-intent clicks, which room page lost visitors before the pricing section, whether traffic is trending up or down against the same period last year. Three numbers, in plain language, each mapped to a decision the owner can actually make. That report is separate work. This commit is the plumbing it runs on.
A property at this scale historically couldn’t sustain three cost lines simultaneously: maintaining analytics infrastructure, operating an ads account, and translating raw data into operator-readable summaries on a weekly cadence. AI tooling compresses all three enough that the full stack becomes viable for operators running two or three rooms. But viable only if the upstream is recording data. The downstream pipeline was already built. The upstream for this property was the gap.
It’s closed now.