In followup on my previous post, WP Statistics has told me for a while now one thing very reliably: people are coming from Facebook. What it couldn’t tell me was which post, which link, or whether a click actually came from something I posted versus someone just sharing my URL manually. Every visit from Facebook just landed in the same generic bucket.
Since I’d already built a proper posting pipeline to Facebook — complete with a local MCP server, backdated historical posts, and Home Assistant sensors for page stats — the obvious next step was making sure I could actually measure whether any of it was working.
The Premium wall
My first instinct was to check whether WP Statistics could already do this. Turns out there’s a “Campaign (UTM) analytics” feature — but it’s gated behind the $119/year Premium tier. Worse, even the more basic “Query Parameter” filter under Page Insights, which I initially thought was a free-tier feature based on their docs, turned out to be Premium-locked in my actual installation too.
Given everything else I’ve built by bypassing paywalls I didn’t need (looking at you, SmartThings API), paying for a whole analytics suite just to see UTM parameters felt like the wrong call. So I built it myself.
The constraint: an externally hosted WordPress
Unlike the rest of my stack, rutg3r.com isn’t self-hosted on my own Proxmox infrastructure — it lives on an external hosting provider. That ruled out my usual approach of just SSHing in and dropping a file wherever I wanted. No direct filesystem access meant no traditional mu-plugin deployment.
What I did have was a WordPress MCP adapter connector with a neat trick: a “sandbox” directory (wp-content/novamira-sandbox/) that WordPress auto-loads on every request, functioning exactly like a mu-plugin without needing FTP or file manager access. That became the deployment target.
What it actually tracks
The whole thing is deliberately minimal. A new database table logs exactly five things per click:
- Which post
utm_source,utm_medium,utm_campaign,utm_content- A timestamp
That’s it. No IP address, no user agent, nothing that identifies a visitor — just enough to answer “which posts are getting clicked, and from where.”
The tracking hook itself is a simple template_redirect action:
add_action('template_redirect', function () {
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (empty($_GET['utm_source']) || !is_singular('post')) {
return;
}
global $wpdb;
$wpdb->insert($wpdb->prefix . 'rutger_utm_clicks', [
'post_id' => get_queried_object_id(),
'utm_source' => sanitize_text_field(wp_unslash($_GET['utm_source'] ?? '')),
'utm_medium' => sanitize_text_field(wp_unslash($_GET['utm_medium'] ?? '')),
'utm_campaign' => sanitize_text_field(wp_unslash($_GET['utm_campaign'] ?? '')),
'utm_content' => sanitize_text_field(wp_unslash($_GET['utm_content'] ?? '')),
'created_at' => current_time('mysql'),
]);
});
I deliberately didn’t hardcode utm_source to only care about Facebook — it logs whatever value shows up. That decision paid off almost immediately: once I turned it on, I discovered my RSS feed had already been tagging outbound links with utm_source=rss on its own, and — more surprisingly — chatgpt.com started showing up as a referrer too. Apparently ChatGPT has been citing and linking to some of my posts directly, and those clicks tag themselves automatically. I had no idea until the data showed it.
Exposing it: a small REST endpoint
Alongside the tracking hook, the same sandbox file registers a token-protected REST endpoint:
add_action('rest_api_init', function () {
register_rest_route('rutger/v1', '/utm-clicks', [
'methods' => 'GET',
'callback' => function (WP_REST_Request $request) {
// ...queries grouped by today / yesterday / 28 days / lifetime,
// both totals and broken down by source and by post
},
'permission_callback' => function (WP_REST_Request $request) {
$token = $request->get_header('X-Rutger-Token');
return hash_equals('my-token-here', (string) $token);
},
]);
});
It went through a few iterations. My first version only returned a rolling 30-day sum, which looked fine at first — until I graphed it in Home Assistant and realized it just kept climbing every day, because it was re-summing an expanding window rather than showing genuine daily activity. The fix was making the endpoint compute real calendar-day boundaries server-side: today, yesterday, a fixed 28-day window, and a true lifetime counter. Once Home Assistant was reading actual daily figures instead of a moving sum, the chart finally looked like a chart instead of a staircase.
One gotcha worth mentioning for anyone doing something similar: PHP opcache cached the old version of my sandbox file for a while after I edited it, so my changes seemed to silently not apply. A manual opcache_reset() sorted it out — worth remembering if you ever edit a live PHP file and the response doesn’t seem to reflect your changes.
Pulling it into Home Assistant
With the endpoint in place, wiring it into Home Assistant was the easy part — four REST sensors (Today, Yesterday, 28 days, Lifetime), each pulling from the same JSON response:
- resource: "https://rutg3r.com/wp-json/rutger/v1/utm-clicks?days=30"
headers:
X-Rutger-Token: !secret rutg3r_utm_clicks_token
scan_interval: 21600
sensor:
- name: "Social Clicks Today"
value_template: "{{ value_json.today_total_clicks }}"
json_attributes:
- today_by_source
# ...and similarly for yesterday, 28d, and lifetime
For the dashboard, I ended up with three cards: a glance row for the headline numbers, a markdown card breaking clicks down by source with an emoji per platform (📘 Facebook, 📰 RSS, 🤖 ChatGPT), and a proper HTML table for the top posts by clicks. That last one taught me something I didn’t expect: Home Assistant’s markdown cards render real Markdown tables only if you use the literal block scalar (content: |) rather than the folded one (content: >) — the folded version collapses every newline into a single line, which silently breaks table syntax. Once I hit that wall twice, I switched to a plain HTML <table> inside the markdown card instead, which sidesteps the whole issue and renders more predictably.
What I actually learned from the data
The most interesting part wasn’t the tooling — it was what showed up once it was running. In the first week:
- RSS was the dominant source, not Facebook — a reminder that a chunk of my regular readers just use a feed reader and never touch the website directly through social at all
- Facebook clicks concentrated heavily on one post (the QNAP syslog-to-MQTT relay writeup), while the historical backfill posts got comparatively little click-through, which makes sense — they’re old content wrapped in a “just happened” framing on the timeline, not urgent enough to click
- ChatGPT is quietly sending readers my way, something I’d have had zero visibility into without source-level attribution
None of this needed a $119/year plugin. It needed a database table, one PHP file, and a handful of Home Assistant sensors — which, if you’ve read anything else on this blog, is pretty much exactly how I approach every other “give me the paywalled feature for free” problem I run into.
How could this looks like in Home Assistant?




