Plausible Analytics through Envoy Gateway
Its rewarding to see view counts on stuff that I write, and until now I had just relied on a quick and dirty Grafana dashboard that showed my top 10 posts with hit counts by crudely scraping logs. It wasn’t great and it was kinda fragile. I’d like to get a bit more information about who is reading my posts, but I also don’t need or want to gather anything intrusive. Plausible came up several times while I had looked for good packages for this need. It only gathers minimal data that I want, and it can be easily selfhosted.
So I deployed Plausible in my homelab and integrated it with this blog as a first step. I had a couple of stumbling points, but it was mostly due to unfamiliarity with Hugo modules. First thing I noticed is that uBlock Origin blocked the analytics script. I’m not surprised. I was serving Plausible from another domain name, so it looked a little sus. I added an http route to make the relevant urls redirect on the backend from my blog to Plausible. That makes it look like normal traffic and my ad blocker no longer cared about it.
I used the following http route to map it:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: blog-jameswynn-com
spec:
parentRefs:
- name: envoy-external
namespace: networking
sectionName: sub-jameswynn-https
hostnames:
- "blog.jameswynn.com"
rules:
- backendRefs:
- name: plausible
port: 8000
matches:
- path:
type: PathPrefix
value: /js/script.js
- path:
type: PathPrefix
value: /api/event
I used the “standard” plausible-hugo module to integrate my blog, which was pretty dead simple. I also did not want to repeat the exercise with every other service I host, which leads me to the real topic of this post: Making Envoy Gateway do the heavy lifting.
Envoy Patch Policies to the Rescue
Envoy Gateway already gets all of the info that I want to see in my dashboards, so I just need to grab the data, reformat it and send it to Plausible’s Event API. This can be done pretty easily with Envoy Patch Policies. They are basically a way to instruct Envoy Gateway to add extra configuration to the underlying Envoy components. They are pretty powerful and these policies only scratch the surface of what can be done.
This policy will have two patches. The first patch utilizes a Lua script to pull the headers, filter to the desired domains, create the payload, and send it to an endpoint for Plausible. The second patch defines a “cluster” endpoint. In theory you could reference the cluster that Envoy creates for the Plausible http route, but it would be really fragile.
For this example Plausible is deployed with the service name “plausible” and is in the “plausible” namespace. The target Envoy proxy is named “envoy-external”.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyPatchPolicy
metadata:
name: plausible-lua-filter
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: envoy-external
type: JSONPatch
jsonPatches:
- type: "type.googleapis.com/envoy.config.listener.v3.Listener"
# this is <namespace>/<gateway-name>/<section-name>
name: networking/envoy-external/https
operation:
op: add
path: "/filter_chains/0/filters/0/typed_config/http_filters/0"
value:
name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
default_source_code:
inline_string: |
function envoy_on_request(request_handle)
local h = request_handle:headers()
local path = h:get(":path") or "/"
local host = h:get(":authority") or "example.com"
local ua = h:get("user-agent") or ""
local xff = h:get("x-forwarded-for") or ""
local client_ip = xff:match("([^,]+)") or xff
-- Strip port if present
local hostname = host:match("([^:]+)")
-- Only track subdomains of example.com (and example.com itself)
if not (hostname == "example.com" or hostname:sub(-#".example.com") == ".example.com") then
return
end
-- Build the Plausible request body
local body = '{"name":"pageview","url":"https://'
.. host .. path .. '",'
.. '"domain":"example.com"}'
-- Send the request to Plausible
request_handle:httpCall(
-- This is the "cluster" name defined in the second patch
"plausible_analytics",
{
[":method"] = "POST",
[":path"] = "/api/event",
[":authority"] = "plausible.plausible.svc.cluster.local",
["content-type"] = "application/json",
["user-agent"] = ua,
["x-forwarded-for"] = client_ip,
},
body,
5000,
true
)
end
- type: "type.googleapis.com/envoy.config.cluster.v3.Cluster"
name: &clusterName plausible_analytics
operation:
# Tells the patch to create a new cluster
op: add
path: ""
value:
name: *clusterName
type: STRICT_DNS
dns_lookup_family: V4_ONLY
load_assignment:
cluster_name: *clusterName
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: plausible.plausible.svc.cluster.local
port_value: 8000
It should be noted that you must have support for patch policies enabled in the Envoy Gateway. If you deployed with Helm, then it can be enabled with:
config:
envoyGateway:
extensionApis:
enableEnvoyPatchPolicy: true
This is a pretty quick way to show all of the traffic from all sub-domains in Plausible. Unfortunately Plausible doesn’t have a simple way to show the sub-domain traffic separately, but you can define sets of sub-domains as a Segment and drill down that way.
Next Steps
This was pretty satisfying, but I will probably tweak the Lua script to use a domain map for grouping sub-domains into different Plausible domains to get better analytics.