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.