I wanted to know how many people visit my websites. I have this blog that people probably do not look at, Case to Calibre and a few other personal projects that are deployed and live on the internet
The analytics are selfishly just for me. I want to know whether somebody found a post/which watch people opened/whether a post got some traction. I am curious if this has gained any traction or if the datacenter in Atlanta really thinks I'm that popular.
Netlify has analytics, but it was not included in the part of the plan I was willing to pay for (LFGO for being grandfathered into their 2017 plan). Their proposed solution involved giving them more money each month. Respect the hustle, but figured I'd build my own out.
Netlify gave me a price instead of a number
Since I've been using Netlify for almost 10 years, paying for its analytics would have been quick and easy: turn it on, look at a dash - no brain no headache. But I didn't want to pay.
The blog runs on Astro and uses client-side navigation. Dropping the standard Google tag into the page would catch the first load, but I also needed to account for Astro swapping pages without a full browser refresh. Astro fires an astro:page-load event after those navigations, so I listen for it and send the page view myself:
---
const measurementId = "G-XXXXXXXXXX";
---
<script async src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}></script>
<script is:inline define:vars={{ measurementId }}>
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function () {
window.dataLayer.push(arguments);
};
if (!window.__siteAnalyticsInitialized) {
window.gtag("js", new Date());
window.gtag("config", measurementId, { send_page_view: false });
window.__siteAnalyticsInitialized = true;
}
if (!window.__sitePageViewListener) {
document.addEventListener("astro:page-load", () => {
window.gtag("event", "page_view", {
page_location: window.location.href,
page_path: window.location.pathname + window.location.search,
page_title: document.title,
});
});
window.__sitePageViewListener = true;
}
</script>
I turn off the automatic page view because I want one piece of code responsible for sending it. Otherwise the first visit can arrive twice, which is an impressive growth rate until I remember I am the second visitor. The two flags on window keep the script from initializing again or attaching another listener after Astro swaps the page.
Case to Calibre needed its own tag
Case to Calibre already lives in its own application, with its own domain and plenty of behavior that has nothing to do with this blog. I gave it a separate GA4 property and measurement ID instead of sending both sites into the same bucket. That lets me tell the difference between somebody reading a post here and somebody spending 1.3 minutes (average) pulling apart a virtual watch.
I wired that Google tag into the shared app layout so every Case to Calibre page gets the same setup. The tag belongs to the application shell, not to an individual watch page, and the measurement ID stays specific to that site. The blog does the equivalent through its shared Astro head component.
Sharing the setup did not mean sharing the measurement ID. I reused the small pattern: load gtag, initialize it once, and send the current location and title. Google keeps the raw data separate by property. My report combines the parts I want to compare later.
That separation also saved me from making one vague “traffic” number carry too much meaning. A blog page view and a watch-viewer session tell me different things, even if both came from one person avoiding whatever they were supposed to be doing.
I am lazy, I don't want to check multiple dashboards
Once the events started arriving, I could open GA and find the numbers. For the blog, I changed the date range and looked at users and page views. Then I switched properties and did it again for Case to Calibre, trying to remember what I saw on the previous screen. I kept that routine going for almost no time at all.
I wanted one place with yesterday, the last seven days, and the last 30 days for each site. I also wanted the daily rows underneath it so I could look back without relying on my memory of a dashboard I had already stopped opening.
I made a workbook with 3 sheets:
Dashboardholds the numbers I check.Daily Visitorsstores one row per site and date.Propertieskeeps the property IDs and an enabled flag.
The daily rows include unique users, new users, sessions, and page views. That is enough information for a personal site and a watch project.
The sheet does the checking for me
Google Apps Script can call the GA4 Data API and write the results into the workbook. My script reads the enabled properties, requests a date range, and uses the date and site name as a key:
const response = AnalyticsData.Properties.runReport(
{
dateRanges: [{ startDate, endDate }],
dimensions: [{ name: "date" }],
metrics: [
{ name: "totalUsers" },
{ name: "newUsers" },
{ name: "sessions" },
{ name: "screenPageViews" },
],
},
`properties/${propertyId}`,
);
I put the existing rows into a Map before adding the response. Running the same date twice replaces that site's row instead of creating a duplicate. That matters when I backfill the last thirty days or rerun the job after something fails.
A time-based trigger runs the report each afternoon in my timezone. The dashboard formulas use the stored dates to calculate each window, which means I can open the sheet whenever the curiosity returns instead of doing report maintenance before I see a number.
There is one analytical footnote worth keeping in the workbook: users are unique inside a GA4 property. If the same person visits two of my sites, adding those property totals together can count them twice. The combined number tells me how the sites performed, not how many distinct humans know who I am.
I got tired of explaining my own spreadsheet (to myself)
Getting one report working gave me a spreadsheet and a new problem: I would forget half of the setup by the time I wanted to add another site. I did not want to have the same conversation with Codex about metrics, tabs, formulas, and date ranges while pretending I remembered why I had chosen each one. I made a Codex skill for the workflow.
A Codex skill is a set of instructions the agent reads before it starts working. Mine carries the fussy context that lives in my head after building this once. “Visitors” is a wonderfully imprecise word when one screen says users and another says sessions, so the skill names the GA4 metrics and tells Codex where each one belongs.
I gave it the sequence I followed:
- Add the site as a GA4 property and record its IDs.
- Install the Astro tracking component in the shared layout.
- Confirm that navigation sends one page view.
- Add the property to the workbook and enable it.
- Backfill the recent dates, then let the daily trigger take over.
- Check for duplicate rows and formula errors before calling it done.
The Case to Calibre setup made this more useful than a one off template. When I gave Codex that property's ID and Google tag, it could add the site without blending its events into the blog. The skill tells it to keep one GA4 property per site, put the measurement ID into that application's shared layout, and add the property ID to the reporting sheet. One ID collects browser events; the other lets the Data API read the report. They are related enough that I know future me would paste one into the wrong field.
The skill describes the workbook too. Codex knows the dashboard needs yesterday, seven-day, and thirty-day windows, the history sheet uses one row per property and date, and the properties sheet acts as configuration. I can toggle a site with the enabled flag instead of removing code from the script.
This context saves me from asking AI to “make an analytics dashboard” and receiving a handsome workbook with the wrong definition of a visitor. I also put verification into the skill because generated files have a special talent for looking finished. Codex inspects the workbook values and formulas, scans for spreadsheet errors, and renders a preview. That preview catches things a formula check will not, like a chopped-off label or a note squeezed into two unreadable lines.
Google still makes me click a few buttons
The output includes the workbook and the Apps Script that keeps it current. Codex builds and checks both pieces, then gives me the script for the part that needs my Google account. I enable the Analytics Data API, approve permissions, and install the trigger myself. Saving three clicks is not worth handing an agent a broad set of credentials and hoping my future security review appreciates the efficiency.
Now I can ask Codex to run the analytics workflow with a list of sites and get the same structure each time. When I change the report, I update the skill once. It works like the runbook I would have written for myself, except this one can build the workbook and point out when I broke a formula.
Six visitors does not need a strategy
I am not using this data to squeeze more engagement out of anyone. I wanted a private answer to a small question: did somebody show up? Sometimes (majority?) the answer will be no, but that's not going to stop me from building for me. I can publish because I wanted to, check the sheet later, and resist turning six visitors into a content strategy.
The final setup costs me less than upgrading my hosting plan, gives me the daily history I wanted, and works the same way when I add another project. More importantly, I no longer have to click through several analytics properties to satisfy a curiosity that was strong enough to make me build an automated reporting system. Repeat vistor who uses Edge in Tallahassee, I did this for you ❤️