From 7b41ad93cb4c4c1aa699cfc58cbad998cd862db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 30 Sep 2026 21:48:47 +0200 Subject: [PATCH] fix(analytics): count pageviews via GoatCounter API from an edge function Visits to roschaefer.de stopped showing up in GoatCounter. The /gc/count Netlify rewrite made every hit arrive from Netlify's AWS egress IPs, and GoatCounter takes the connecting proxy as the client IP and flags AWS ranges as bots (isbot BotRangeAWS). Those hits were stored as bots and never counted, while the endpoint still answered 200 with a GIF. The edge function records the hit through /api/v0/count instead, passing the visitor's own IP and user agent so bot detection and unique-visitor sessions work again. It keeps the first-party /gc/count path, so ad blockers still don't block the beacon; loading GoatCounter directly was the simpler alternative but loses that. Requires a GoatCounter API token with the "Record pageviews" permission in the GOATCOUNTER_API_TOKEN Netlify environment variable. Verified with pnpm check:quick; not yet exercised on a Netlify deploy. --- netlify.toml | 10 +- netlify/edge-functions/goatcounter.ts | 65 +++++++++++++ .../utils/goatcounter-edge-function.test.ts | 97 +++++++++++++++++++ 3 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 netlify/edge-functions/goatcounter.ts create mode 100644 src/lib/utils/goatcounter-edge-function.test.ts diff --git a/netlify.toml b/netlify.toml index 617a5ec..efd28c2 100644 --- a/netlify.toml +++ b/netlify.toml @@ -52,12 +52,6 @@ to = "https://gc.zgo.at/count.js" status = 200 force = true -[[redirects]] -from = "/gc/count" -to = "https://roschaefer.goatcounter.com/count" -status = 200 -force = true - [[redirects]] from = "/oss-contributors-berlin" to = "https://www.youtube.com/watch?v=p5iMjfVCXBY" @@ -101,3 +95,7 @@ path = "/" [[edge_functions]] function = "locale-redirect" path = "/resume.json" + +[[edge_functions]] +function = "goatcounter" +path = "/gc/count" diff --git a/netlify/edge-functions/goatcounter.ts b/netlify/edge-functions/goatcounter.ts new file mode 100644 index 0000000..d9070c4 --- /dev/null +++ b/netlify/edge-functions/goatcounter.ts @@ -0,0 +1,65 @@ +declare const Netlify: { env: { get: (name: string) => string | undefined } }; + +type Context = { ip: string; waitUntil: (promise: Promise) => void }; + +export const goatcounterApiUrl = "https://roschaefer.goatcounter.com/api/v0/count"; + +// The same 1x1 GIF that GoatCounter's own /count endpoint responds with. +const gif = Uint8Array.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x1, 0x0, 0x1, 0x0, 0x80, 0x1, 0x0, 0x0, 0x0, 0x0, 0xff, 0xff, + 0xff, 0x21, 0xf9, 0x4, 0x1, 0xa, 0x0, 0x1, 0x0, 0x2c, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x1, 0x0, 0x0, + 0x2, 0x2, 0x4c, 0x1, 0x0, 0x3b, +]); + +const gifResponse = () => + new Response(gif, { + headers: { "content-type": "image/gif", "cache-control": "no-store" }, + }); + +const primaryLanguage = (acceptLanguage: string | null): string => + acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? ""; + +export const toApiHit = (request: Request, ip: string) => { + const params = new URL(request.url).searchParams; + const path = params.get("p"); + if (!path) { + return null; + } + + return { + path, + title: params.get("t") ?? "", + ref: params.get("r") ?? "", + event: params.get("e") === "true", + size: params.get("s") ?? "", + query: params.get("q") ?? "", + bot: Number.parseInt(params.get("b") ?? "0", 10) || 0, + user_agent: request.headers.get("user-agent") ?? "", + language: primaryLanguage(request.headers.get("accept-language")), + ip, + }; +}; + +const send = async (hit: NonNullable>, token: string) => { + const response = await fetch(goatcounterApiUrl, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ hits: [hit] }), + }); + if (!response.ok) { + console.error(`GoatCounter API responded ${response.status}: ${await response.text()}`); + } +}; + +export default async (request: Request, context: Context) => { + const hit = toApiHit(request, context.ip); + const token = Netlify.env.get("GOATCOUNTER_API_TOKEN"); + + if (!token) { + console.error("GOATCOUNTER_API_TOKEN is not set, not counting"); + } else if (hit) { + context.waitUntil(send(hit, token).catch((error) => console.error(error))); + } + + return gifResponse(); +}; diff --git a/src/lib/utils/goatcounter-edge-function.test.ts b/src/lib/utils/goatcounter-edge-function.test.ts new file mode 100644 index 0000000..b9e534f --- /dev/null +++ b/src/lib/utils/goatcounter-edge-function.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import countPageview, { goatcounterApiUrl } from "../../../netlify/edge-functions/goatcounter.ts"; + +const visitorIp = "203.0.113.7"; +const firefox = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"; + +const beacon = (query: string) => + new Request(`https://roschaefer.de/gc/count?${query}`, { + method: "POST", + headers: { "user-agent": firefox, "accept-language": "de-DE,de;q=0.9,en;q=0.8" }, + }); + +const run = async (request: Request) => { + const pending: Promise[] = []; + const response = await countPageview(request, { + ip: visitorIp, + waitUntil: (promise) => pending.push(promise), + }); + await Promise.all(pending); + return response; +}; + +let token: string | undefined; +const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + +beforeEach(() => { + token = "secret-token"; + vi.stubGlobal("Netlify", { env: { get: () => token } }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockClear(); +}); + +describe("goatcounter edge function", () => { + it("reports the visitor's own IP and user agent, so GoatCounter does not see the proxy's datacenter IP and discard the hit as a bot", async () => { + await run(beacon("p=%2Fen%2F&t=Robert&r=https%3A%2F%2Fexample.com%2F&s=1920&b=0&rnd=x")); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(goatcounterApiUrl); + expect(init.headers).toMatchObject({ authorization: "Bearer secret-token" }); + expect(JSON.parse(init.body as string)).toEqual({ + hits: [ + { + path: "/en/", + title: "Robert", + ref: "https://example.com/", + event: false, + size: "1920", + query: "", + bot: 0, + user_agent: firefox, + language: "de-DE", + ip: visitorIp, + }, + ], + }); + }); + + it("responds with a no-store GIF like GoatCounter's pixel endpoint", async () => { + const response = await run(beacon("p=%2Fen%2F")); + + expect(response.headers.get("content-type")).toBe("image/gif"); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("forwards events and the script's own bot detection", async () => { + await run(beacon("p=download-cv&e=true&b=150")); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string).hits[0]).toMatchObject({ + path: "download-cv", + event: true, + bot: 150, + }); + }); + + it("does not call the API for requests without a path", async () => { + await run(beacon("rnd=x")); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("still responds when the API token is not configured", async () => { + token = undefined; + vi.spyOn(console, "error").mockImplementation(() => {}); + + const response = await run(beacon("p=%2Fen%2F")); + + expect(response.status).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});