From cbc6a5173b977a95a2b662e630be09f01d08e502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Thu, 1 Oct 2026 21:52:32 +0200 Subject: [PATCH 1/5] feat(analytics): count CV and resume.json downloads server-side The GoatCounter JS beacon only runs on HTML pages, so direct requests for the PDF CVs and the JSON Resume were invisible -- e.g. a CV link opened from a printed CV or an email. A Netlify edge function on those paths now reports each download to the GoatCounter API, reusing the API sender and GOATCOUNTER_API_TOKEN of the /gc/count proxy. GoatCounter's API path skips the bot detection of its /count endpoint, so crawlers, link previews and scripts are filtered by user agent here. Prefetches, HEAD requests, failed responses and a PDF viewer's follow-up byte ranges are not counted either, so one download is one hit. /resume.json is not counted itself: it redirects to /de|en/resume.json, which are. Verified with pnpm check:quick; e2e tests not run. --- netlify.toml | 24 +++ netlify/edge-functions/count-download.ts | 66 ++++++++ netlify/edge-functions/goatcounter.ts | 6 +- .../count-download-edge-function.test.ts | 151 ++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 netlify/edge-functions/count-download.ts create mode 100644 src/lib/utils/count-download-edge-function.test.ts diff --git a/netlify.toml b/netlify.toml index efd28c2..cce0649 100644 --- a/netlify.toml +++ b/netlify.toml @@ -99,3 +99,27 @@ path = "/resume.json" [[edge_functions]] function = "goatcounter" path = "/gc/count" + +[[edge_functions]] +function = "count-download" +path = "/de/robert-schaefer-resume.de.pdf" + +[[edge_functions]] +function = "count-download" +path = "/en/robert-schaefer-resume.en.pdf" + +[[edge_functions]] +function = "count-download" +path = "/de/robert-schaefer-resume-ats.de.pdf" + +[[edge_functions]] +function = "count-download" +path = "/en/robert-schaefer-resume-ats.en.pdf" + +[[edge_functions]] +function = "count-download" +path = "/de/resume.json" + +[[edge_functions]] +function = "count-download" +path = "/en/resume.json" diff --git a/netlify/edge-functions/count-download.ts b/netlify/edge-functions/count-download.ts new file mode 100644 index 0000000..0f872fc --- /dev/null +++ b/netlify/edge-functions/count-download.ts @@ -0,0 +1,66 @@ +import { primaryLanguage, send } from "./goatcounter.ts"; + +declare const Netlify: { env: { get: (name: string) => string | undefined } }; + +type Context = { + ip: string; + next: () => Promise; + waitUntil: (promise: Promise) => void; +}; + +// GoatCounter's API trusts the hits it receives and skips the bot checks its +// own /count endpoint does, so crawlers, link previews and scripts are +// filtered here by user agent. +const botUserAgent = + /bot|crawl|spider|slurp|preview|facebookexternalhit|whatsapp|curl|wget|python|go-http-client|okhttp|java\/|libwww|httpclient|axios|node-fetch|undici|headless|lighthouse|pingdom|uptime|monitor|scan/i; + +const isBot = (userAgent: string | null): boolean => !userAgent || botUserAgent.test(userAgent); + +const isPrefetch = (request: Request): boolean => + /prefetch|prerender/i.test( + `${request.headers.get("sec-purpose") ?? ""} ${request.headers.get("purpose") ?? ""}`, + ); + +// PDF viewers fetch large files in byte ranges after the first request; only +// the range that starts at the beginning counts as a download. +const isFollowUpRange = (request: Request): boolean => { + const range = request.headers.get("range"); + return range !== null && !/^bytes=0-/.test(range.trim()); +}; + +const shouldCount = (request: Request): boolean => + request.method === "GET" && + !isBot(request.headers.get("user-agent")) && + !isPrefetch(request) && + !isFollowUpRange(request); + +export default async (request: Request, context: Context) => { + const response = await context.next(); + + if (!shouldCount(request) || response.status >= 400) { + return response; + } + + const token = Netlify.env.get("GOATCOUNTER_API_TOKEN"); + if (!token) { + console.error("GOATCOUNTER_API_TOKEN is not set, not counting"); + return response; + } + + const url = new URL(request.url); + const hit = { + path: url.pathname, + title: "", + ref: request.headers.get("referer") ?? "", + event: false, + size: "", + query: url.search, + bot: 0, + user_agent: request.headers.get("user-agent") ?? "", + language: primaryLanguage(request.headers.get("accept-language")), + ip: context.ip, + }; + context.waitUntil(send(hit, token).catch((error) => console.error(error))); + + return response; +}; diff --git a/netlify/edge-functions/goatcounter.ts b/netlify/edge-functions/goatcounter.ts index d9070c4..92abdea 100644 --- a/netlify/edge-functions/goatcounter.ts +++ b/netlify/edge-functions/goatcounter.ts @@ -16,7 +16,7 @@ const gifResponse = () => headers: { "content-type": "image/gif", "cache-control": "no-store" }, }); -const primaryLanguage = (acceptLanguage: string | null): string => +export const primaryLanguage = (acceptLanguage: string | null): string => acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? ""; export const toApiHit = (request: Request, ip: string) => { @@ -40,7 +40,9 @@ export const toApiHit = (request: Request, ip: string) => { }; }; -const send = async (hit: NonNullable>, token: string) => { +export type ApiHit = NonNullable>; + +export const send = async (hit: ApiHit, token: string) => { const response = await fetch(goatcounterApiUrl, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, diff --git a/src/lib/utils/count-download-edge-function.test.ts b/src/lib/utils/count-download-edge-function.test.ts new file mode 100644 index 0000000..2420e95 --- /dev/null +++ b/src/lib/utils/count-download-edge-function.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resumePdfPath } from "$lib/data/resume-pdf"; +import countDownload from "../../../netlify/edge-functions/count-download.ts"; +import { 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 pdfUrl = "https://roschaefer.de/en/robert-schaefer-resume.en.pdf"; + +const download = (url: string, headers: Record = {}, method = "GET") => + new Request(url, { + method, + headers: { "user-agent": firefox, "accept-language": "en-US,en;q=0.9", ...headers }, + }); + +let fileResponse: Response; + +const run = async (request: Request) => { + const pending: Promise[] = []; + const response = await countDownload(request, { + ip: visitorIp, + next: async () => fileResponse, + 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"; + fileResponse = new Response("%PDF-1.7", { headers: { "content-type": "application/pdf" } }); + vi.stubGlobal("Netlify", { env: { get: () => token } }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + fetchMock.mockClear(); +}); + +describe("count-download edge function", () => { + it("counts a direct download that the JS beacon never sees, e.g. a CV link opened from a printout or email", async () => { + const response = await run(download(pdfUrl, { referer: "https://mail.example.com/" })); + + expect(response).toBe(fileResponse); + 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/robert-schaefer-resume.en.pdf", + title: "", + ref: "https://mail.example.com/", + event: false, + size: "", + query: "", + bot: 0, + user_agent: firefox, + language: "en-US", + ip: visitorIp, + }, + ], + }); + }); + + it.each([ + [ + "a search engine crawler", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + ], + ["a chat link preview", "WhatsApp/2.23.20.0"], + ["a social link preview", "facebookexternalhit/1.1"], + ["a script", "curl/8.10.1"], + [ + "a headless browser", + "Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/130.0.0.0 Safari/537.36", + ], + ["a client without user agent", ""], + ])( + "does not count %s, because GoatCounter's API skips its own bot detection", + async (_, userAgent) => { + await run(download(pdfUrl, { "user-agent": userAgent })); + + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("does not count speculative prefetches", async () => { + await run(download(pdfUrl, { "sec-purpose": "prefetch;prerender" })); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("counts a PDF viewer's first byte range but not the follow-up ranges of the same download", async () => { + await run(download(pdfUrl, { range: "bytes=0-65535" })); + await run(download(pdfUrl, { range: "bytes=65536-131071" })); + + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("does not count HEAD requests", async () => { + await run(download(pdfUrl, {}, "HEAD")); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not count failed responses", async () => { + fileResponse = new Response("Not found", { status: 404 }); + + await run(download(pdfUrl)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("still serves the file when the API token is not configured", async () => { + token = undefined; + vi.spyOn(console, "error").mockImplementation(() => {}); + + const response = await run(download(pdfUrl)); + + expect(response).toBe(fileResponse); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("count-download routes in netlify.toml", () => { + const countedPaths = readFileSync(resolve(process.cwd(), "netlify.toml"), "utf8") + .split("[[edge_functions]]") + .slice(1) + .filter((block) => block.includes('function = "count-download"')) + .map((block) => block.match(/path = "([^"]+)"/)?.[1]); + + it.each( + (["de", "en"] as const).flatMap((locale) => [ + resumePdfPath(locale), + resumePdfPath(locale, "ats"), + `/${locale}/resume.json`, + ]), + )("counts %s", (path) => { + expect(countedPaths).toContain(path); + }); +}); From 40b618361afaf8a4e7476b622a20ea333d938c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Thu, 1 Oct 2026 22:50:29 +0200 Subject: [PATCH 2/5] feat(analytics): count CV and resume.json downloads server-side The GoatCounter JS beacon only runs on HTML pages, so direct requests for the PDF CVs and the JSON Resume were invisible -- e.g. a CV link opened from a printed CV or an email. A Netlify edge function on those paths now reports each download to the GoatCounter API, reusing the API sender and GOATCOUNTER_API_TOKEN of the /gc/count proxy. Unlike the beacon, a file request is not proof of a rendered view: prefetches, HEAD requests, failed responses and a PDF viewer's follow-up byte ranges are not counted, so one download is one hit. Bot detection is left to GoatCounter: its API flags hits by the forwarded user agent. Only its IP-range check is skipped on the API path, so bots with browser user agents from datacenter IPs are counted -- an accepted gap rather than duplicating GoatCounter's IP lists. /resume.json is not counted itself: it redirects to /de|en/resume.json, which are. Verified with pnpm check:quick; e2e tests not run. --- netlify/edge-functions/count-download.ts | 13 +------- .../count-download-edge-function.test.ts | 32 +++++++------------ 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/netlify/edge-functions/count-download.ts b/netlify/edge-functions/count-download.ts index 0f872fc..19bd632 100644 --- a/netlify/edge-functions/count-download.ts +++ b/netlify/edge-functions/count-download.ts @@ -8,14 +8,6 @@ type Context = { waitUntil: (promise: Promise) => void; }; -// GoatCounter's API trusts the hits it receives and skips the bot checks its -// own /count endpoint does, so crawlers, link previews and scripts are -// filtered here by user agent. -const botUserAgent = - /bot|crawl|spider|slurp|preview|facebookexternalhit|whatsapp|curl|wget|python|go-http-client|okhttp|java\/|libwww|httpclient|axios|node-fetch|undici|headless|lighthouse|pingdom|uptime|monitor|scan/i; - -const isBot = (userAgent: string | null): boolean => !userAgent || botUserAgent.test(userAgent); - const isPrefetch = (request: Request): boolean => /prefetch|prerender/i.test( `${request.headers.get("sec-purpose") ?? ""} ${request.headers.get("purpose") ?? ""}`, @@ -29,10 +21,7 @@ const isFollowUpRange = (request: Request): boolean => { }; const shouldCount = (request: Request): boolean => - request.method === "GET" && - !isBot(request.headers.get("user-agent")) && - !isPrefetch(request) && - !isFollowUpRange(request); + request.method === "GET" && !isPrefetch(request) && !isFollowUpRange(request); export default async (request: Request, context: Context) => { const response = await context.next(); diff --git a/src/lib/utils/count-download-edge-function.test.ts b/src/lib/utils/count-download-edge-function.test.ts index 2420e95..02dcf49 100644 --- a/src/lib/utils/count-download-edge-function.test.ts +++ b/src/lib/utils/count-download-edge-function.test.ts @@ -72,27 +72,17 @@ describe("count-download edge function", () => { }); }); - it.each([ - [ - "a search engine crawler", - "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", - ], - ["a chat link preview", "WhatsApp/2.23.20.0"], - ["a social link preview", "facebookexternalhit/1.1"], - ["a script", "curl/8.10.1"], - [ - "a headless browser", - "Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/130.0.0.0 Safari/537.36", - ], - ["a client without user agent", ""], - ])( - "does not count %s, because GoatCounter's API skips its own bot detection", - async (_, userAgent) => { - await run(download(pdfUrl, { "user-agent": userAgent })); - - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); + it("leaves bot detection to GoatCounter, whose API flags hits by the forwarded user agent", async () => { + const googlebot = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + + await run(download(pdfUrl, { "user-agent": googlebot })); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string).hits[0]).toMatchObject({ + user_agent: googlebot, + bot: 0, + }); + }); it("does not count speculative prefetches", async () => { await run(download(pdfUrl, { "sec-purpose": "prefetch;prerender" })); From e37b24e6857dbd3b6a34c9ee3116bde0d490cc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Thu, 1 Oct 2026 22:58:45 +0200 Subject: [PATCH 3/5] perf(analytics): keep conditional requests for counted downloads context.next() strips If-None-Match/If-Modified-Since by default, so a browser revalidating a cached CV PDF got the full file again instead of a 304. The download counter doesn't transform the body, so it can pass conditional requests through. A 304 is still counted: the visitor opened the file again. --- netlify/edge-functions/count-download.ts | 4 ++-- .../utils/count-download-edge-function.test.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/netlify/edge-functions/count-download.ts b/netlify/edge-functions/count-download.ts index 19bd632..31a41f3 100644 --- a/netlify/edge-functions/count-download.ts +++ b/netlify/edge-functions/count-download.ts @@ -4,7 +4,7 @@ declare const Netlify: { env: { get: (name: string) => string | undefined } }; type Context = { ip: string; - next: () => Promise; + next: (options?: { sendConditionalRequest?: boolean }) => Promise; waitUntil: (promise: Promise) => void; }; @@ -24,7 +24,7 @@ const shouldCount = (request: Request): boolean => request.method === "GET" && !isPrefetch(request) && !isFollowUpRange(request); export default async (request: Request, context: Context) => { - const response = await context.next(); + const response = await context.next({ sendConditionalRequest: true }); if (!shouldCount(request) || response.status >= 400) { return response; diff --git a/src/lib/utils/count-download-edge-function.test.ts b/src/lib/utils/count-download-edge-function.test.ts index 02dcf49..e5ea400 100644 --- a/src/lib/utils/count-download-edge-function.test.ts +++ b/src/lib/utils/count-download-edge-function.test.ts @@ -22,13 +22,15 @@ const run = async (request: Request) => { const pending: Promise[] = []; const response = await countDownload(request, { ip: visitorIp, - next: async () => fileResponse, + next: nextMock, waitUntil: (promise) => pending.push(promise), }); await Promise.all(pending); return response; }; +const nextMock = vi.fn(async (_options?: { sendConditionalRequest?: boolean }) => fileResponse); + let token: string | undefined; const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); @@ -43,6 +45,7 @@ afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); fetchMock.mockClear(); + nextMock.mockClear(); }); describe("count-download edge function", () => { @@ -84,6 +87,16 @@ describe("count-download edge function", () => { }); }); + it("lets the CDN answer revalidations with 304 instead of resending the whole file, and counts them as views", async () => { + fileResponse = new Response(null, { status: 304 }); + + const response = await run(download(pdfUrl, { "if-none-match": '"abc"' })); + + expect(nextMock).toHaveBeenCalledWith({ sendConditionalRequest: true }); + expect(response.status).toBe(304); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it("does not count speculative prefetches", async () => { await run(download(pdfUrl, { "sec-purpose": "prefetch;prerender" })); From 7844fd783c7b13c563e58e69a63786255e0f8b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 00:11:46 +0200 Subject: [PATCH 4/5] fix(analytics): count downloads only on the production hostname If GOATCOUNTER_API_TOKEN is available to deploy previews or branch deploys, opening a CV there was recorded in the production GoatCounter site. The JS beacon already avoids this by checking the hostname against siteUrl; the download counter now uses the same check, so it doesn't depend on how the token is scoped in Netlify. The tradeoff is that counting can only be verified after merging. --- netlify/edge-functions/count-download.ts | 8 +++++++- src/lib/utils/count-download-edge-function.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/netlify/edge-functions/count-download.ts b/netlify/edge-functions/count-download.ts index 31a41f3..4e77619 100644 --- a/netlify/edge-functions/count-download.ts +++ b/netlify/edge-functions/count-download.ts @@ -1,3 +1,4 @@ +import { siteUrl } from "../../src/lib/config/site.ts"; import { primaryLanguage, send } from "./goatcounter.ts"; declare const Netlify: { env: { get: (name: string) => string | undefined } }; @@ -8,6 +9,8 @@ type Context = { waitUntil: (promise: Promise) => void; }; +const productionHostname = new URL(siteUrl).hostname; + const isPrefetch = (request: Request): boolean => /prefetch|prerender/i.test( `${request.headers.get("sec-purpose") ?? ""} ${request.headers.get("purpose") ?? ""}`, @@ -21,7 +24,10 @@ const isFollowUpRange = (request: Request): boolean => { }; const shouldCount = (request: Request): boolean => - request.method === "GET" && !isPrefetch(request) && !isFollowUpRange(request); + new URL(request.url).hostname === productionHostname && + request.method === "GET" && + !isPrefetch(request) && + !isFollowUpRange(request); export default async (request: Request, context: Context) => { const response = await context.next({ sendConditionalRequest: true }); diff --git a/src/lib/utils/count-download-edge-function.test.ts b/src/lib/utils/count-download-edge-function.test.ts index e5ea400..557d18e 100644 --- a/src/lib/utils/count-download-edge-function.test.ts +++ b/src/lib/utils/count-download-edge-function.test.ts @@ -97,6 +97,16 @@ describe("count-download edge function", () => { expect(fetchMock).toHaveBeenCalledOnce(); }); + it("does not count downloads from deploy previews, so reviewing a PR does not pollute the production stats", async () => { + await run( + download( + "https://deploy-preview-145--roschaefer.netlify.app/en/robert-schaefer-resume.en.pdf", + ), + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("does not count speculative prefetches", async () => { await run(download(pdfUrl, { "sec-purpose": "prefetch;prerender" })); From 91e8ec9a5970c415e4edf24f22f5d7cf39415c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 00:46:57 +0200 Subject: [PATCH 5/5] docs(privacy): say GoatCounter measures content, not just pages CV PDF and resume.json downloads are now counted server-side, so "which pages are visited" no longer described what is measured. "Content" covers both without listing every counted file in the policy. --- messages/de.json | 2 +- messages/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4440bce..04939b2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -108,7 +108,7 @@ "privacy_hosting_title": "Hosting und Server-Logs", "privacy_hosting_body": "Diese Website wird bei Netlify gehostet. Beim Aufruf der Website verarbeitet Netlify technische Zugriffsdaten wie IP-Adresse, Zeitpunkt, abgerufene Datei, Referrer und Browserinformationen.", "privacy_analytics_title": "Reichweitenmessung mit GoatCounter", - "privacy_analytics_body": "Diese Website verwendet GoatCounter für datensparsame Reichweitenmessung. GoatCounter hilft zu verstehen, welche Seiten aufgerufen werden, ohne Cookies, Local Storage oder Tracking-IDs zu verwenden. Nach Angaben von GoatCounter werden IP-Adressen und vollständige User-Agent-Header nicht dauerhaft gespeichert; zur Erkennung wiederholter Aufrufe können technische Daten bis zu acht Stunden im Arbeitsspeicher verarbeitet werden.", + "privacy_analytics_body": "Diese Website verwendet GoatCounter für datensparsame Reichweitenmessung. GoatCounter hilft zu verstehen, welche Inhalte dieser Website aufgerufen werden, ohne Cookies, Local Storage oder Tracking-IDs zu verwenden. Nach Angaben von GoatCounter werden IP-Adressen und vollständige User-Agent-Header nicht dauerhaft gespeichert; zur Erkennung wiederholter Aufrufe können technische Daten bis zu acht Stunden im Arbeitsspeicher verarbeitet werden.", "privacy_contact_title": "Kontaktaufnahme", "privacy_contact_body": "Wenn Sie per E-Mail oder Signal Kontakt aufnehmen, werden die von Ihnen übermittelten Daten zur Bearbeitung der Anfrage verarbeitet.", "privacy_external_title": "Externe Links und eingebettete Inhalte", diff --git a/messages/en.json b/messages/en.json index 10e505a..da16079 100644 --- a/messages/en.json +++ b/messages/en.json @@ -108,7 +108,7 @@ "privacy_hosting_title": "Hosting and server logs", "privacy_hosting_body": "This website is hosted by Netlify. When you access the website, Netlify processes technical access data such as IP address, time, requested file, referrer, and browser information.", "privacy_analytics_title": "Analytics with GoatCounter", - "privacy_analytics_body": "This website uses GoatCounter for privacy-friendly analytics. GoatCounter helps understand which pages are visited without using cookies, localStorage, or tracking IDs. According to GoatCounter, IP addresses and full user agent headers are not stored permanently; technical data may be processed in memory for up to eight hours to detect repeat visits.", + "privacy_analytics_body": "This website uses GoatCounter for privacy-friendly analytics. GoatCounter helps understand which content on this website is accessed without using cookies, localStorage, or tracking IDs. According to GoatCounter, IP addresses and full user agent headers are not stored permanently; technical data may be processed in memory for up to eight hours to detect repeat visits.", "privacy_contact_title": "Contact", "privacy_contact_body": "If you contact me by email or Signal, the data you provide is processed to handle your request.", "privacy_external_title": "External links and embedded content",