From 1c4e43b195a267eb742dbee7a74373c3f73bdee6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 24 Oct 2026 15:19:19 -0400 Subject: [PATCH 1/3] fix: deliver GoatCounter visits through backend API --- .env.example | 5 + docs/deploying_volview.md | 29 +++++ netlify/edge-functions/visits.ts | 91 ++++++++++----- tests/visits.test.ts | 187 +++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 tests/visits.test.ts diff --git a/.env.example b/.env.example index ff34c3226..4579e8f89 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ VITE_REMOTE_SERVER_URL=http://localhost:4014 # Controls the initial visibility of the Sample Data section. VITE_SHOW_SAMPLE_DATA=true + +# Optional Netlify Edge Function analytics. Set these in Netlify's Functions +# scope for the deployment contexts to track. Never use a VITE_ prefix for tokens. +GOATCOUNTER_SITE= +GOATCOUNTER_API_TOKEN= diff --git a/docs/deploying_volview.md b/docs/deploying_volview.md index 3dcdfcfe7..4371efbdb 100644 --- a/docs/deploying_volview.md +++ b/docs/deploying_volview.md @@ -11,6 +11,35 @@ As a local example, running `npx serve dist/` will spin up a static file server Please refer to your hosting provider's documentation on how to host a static site. You will need to upload the built files in `dist/` to wherever your provider specifies. +### Netlify visit analytics + +The `visits` Edge Function records GET requests to `/` using GoatCounter's +[backend API](https://www.goatcounter.com/help/backend). To enable it: + +1. In GoatCounter, open your username menu, then **API**, and create a token + with permission to count pageviews. +2. In Netlify, open **Project configuration > Environment variables** and set + `GOATCOUNTER_SITE` to your GoatCounter site URL (for example, + `https://volview.goatcounter.com`) and `GOATCOUNTER_API_TOKEN` to that token. + Both variables need the **Functions** scope. Set values only in the deploy + contexts you intend to track; leave previews and local development unset. + The token must remain server-side and must not have a `VITE_` prefix. +3. Redeploy. Edge Functions pick up environment variable changes on deployment. + Ensure Netlify Split Testing is disabled, since it prevents Edge Functions + from running. + +Deployments that already have `GOATCOUNTER_SITE` also need the API token. Without +it, the function skips tracking and logs a configuration warning. Without a site +URL, analytics stays disabled silently. + +The function uses `context.waitUntil()` to finish tracking after serving the page, +with a 10-second request timeout. Under **Cloud compute > Edge functions**, select +`visits` to see accepted or filtered pageviews, HTTP rejection statuses, and +network failures. Logs exclude tokens, visitor IP addresses, request URLs, and +response bodies. The function sends the host and path, referrer origin and path, +user agent, and visitor IP needed by GoatCounter for sessions and location; +query strings and fragments are omitted. + ## Self-hosted Server (nginx, apache2, etc.) Please refer to your desired server's documentation on how to serve static files. You will need to upload the built files in `dist/` to the static file directory on your server. Several examples are provided below. diff --git a/netlify/edge-functions/visits.ts b/netlify/edge-functions/visits.ts index 1803d213f..b0dddc1c9 100644 --- a/netlify/edge-functions/visits.ts +++ b/netlify/edge-functions/visits.ts @@ -1,38 +1,75 @@ -// This file runs in the Deno runtime +/* global Netlify */ +import type { Context } from '@netlify/edge-functions'; -// @ts-ignore -// eslint-disable-next-line import/no-unresolved -import { Context } from 'https://edge.netlify.com'; - -// @ts-ignore -const GOATCOUNTER_SITE = Deno.env.get('GOATCOUNTER_SITE'); - -export default async (request: Request, context: Context) => { - if (!GOATCOUNTER_SITE) return; - - const headers = { 'X-Forwarded-For': context.ip }; - const url = new URL(GOATCOUNTER_SITE); - const { searchParams } = url; - - url.pathname = '/count'; +function getReferrer(request: Request) { + const referrer = request.headers.get('Referer'); + if (!referrer) return undefined; + try { + const url = new URL(referrer); + return `${url.origin}${url.pathname}`; + } catch { + return undefined; + } +} - const requestUrl = new URL(request.url); - searchParams.set('p', `${requestUrl.host}${requestUrl.pathname}`); - searchParams.set('t', 'VolView'); +async function countVisit( + request: Request, + ip: string, + site: string, + token: string +) { + try { + const requestUrl = new URL(request.url); + const response = await fetch(new URL('/api/v0/count', site), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + redirect: 'error', + signal: AbortSignal.timeout(10_000), + body: JSON.stringify({ + hits: [ + { + path: `${requestUrl.host}${requestUrl.pathname}`, + title: 'VolView', + ref: getReferrer(request), + user_agent: request.headers.get('User-Agent') || 'Unknown', + ip, + }, + ], + }), + }); + await response.body?.cancel(); - if (request.headers.has('Referer')) { - searchParams.set( - 'r', - encodeURIComponent(request.headers.get('Referer') ?? '') + if (response.status !== 202) { + console.error(`GoatCounter request rejected (HTTP ${response.status}).`); + } else if (response.headers.has('X-Goatcounter-Filter')) { + console.info('GoatCounter visit filtered (HTTP 202).'); + } else { + console.info('GoatCounter visit accepted (HTTP 202).'); + } + } catch { + // Errors and response bodies may contain visitor data or credentials. + console.error( + 'GoatCounter request failed. Check configuration and network.' ); } +} + +export default (request: Request, context: Context) => { + if (request.method !== 'GET') return; + + const site = Netlify.env.get('GOATCOUNTER_SITE'); + if (!site) return; - if (request.headers.has('User-Agent')) { - headers['User-Agent'] = request.headers.get('User-Agent') ?? 'Unknown'; + const token = Netlify.env.get('GOATCOUNTER_API_TOKEN'); + if (!token) { + console.warn('GoatCounter disabled: GOATCOUNTER_API_TOKEN is not set.'); + return; } - // don't block the request - fetch(url.toString(), { headers }); + context.waitUntil(countVisit(request, context.ip, site, token)); }; export const config = { path: '/' }; diff --git a/tests/visits.test.ts b/tests/visits.test.ts new file mode 100644 index 000000000..afb459e4a --- /dev/null +++ b/tests/visits.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment node +import { once } from 'node:events'; +import { createServer, IncomingHttpHeaders, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Context } from '@netlify/edge-functions'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import visits from '../netlify/edge-functions/visits'; + +describe('Netlify visit analytics', () => { + const ip = '192.0.2.42'; + const token = 'test-token'; + let env: Record; + let pending: Promise[]; + let server: ReturnType | undefined; + + const request = () => + new Request( + 'https://volview.kitware.app/?urls=private-dataset#private-state', + { + headers: { + 'User-Agent': 'test-browser', + Referer: + 'https://example.com/viewer?token=private-token#private-state', + }, + } + ); + + const context = { + ip, + waitUntil: (task: Promise) => { + pending.push(task); + }, + } as Context; + + async function startEndpoint() { + const received = Promise.withResolvers<{ + method?: string; + url?: string; + headers: IncomingHttpHeaders; + body: unknown; + response: ServerResponse; + }>(); + server = createServer(async (req, response) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + received.resolve({ + method: req.method, + url: req.url, + headers: req.headers, + body: JSON.parse(Buffer.concat(chunks).toString()), + response, + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + env.GOATCOUNTER_SITE = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return received; + } + + beforeEach(() => { + env = { + GOATCOUNTER_SITE: 'https://goatcounter.invalid', + GOATCOUNTER_API_TOKEN: token, + }; + pending = []; + vi.stubGlobal('Netlify', { env: { get: (key: string) => env[key] } }); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + await Promise.all(pending); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('sends an authenticated pageview after allowing the page to continue', async () => { + const received = await startEndpoint(); + expect(visits(request(), context)).toBeUndefined(); + const { response, ...sent } = await received.promise; + + expect(sent).toMatchObject({ + method: 'POST', + url: '/api/v0/count', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: { + hits: [ + { + path: 'volview.kitware.app/', + title: 'VolView', + ref: 'https://example.com/viewer', + user_agent: 'test-browser', + ip, + }, + ], + }, + }); + expect(JSON.stringify(sent.body)).not.toContain('private-'); + expect(console.info).not.toHaveBeenCalled(); + expect(pending).toHaveLength(1); + + response.writeHead(202).end(); + await Promise.all(pending); + expect(console.info).toHaveBeenCalledExactlyOnceWith( + 'GoatCounter visit accepted (HTTP 202).' + ); + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each([400, 401, 403, 429, 503])( + 'reports HTTP %i without logging the response body', + async (status) => { + const received = await startEndpoint(); + visits(request(), context); + const { response } = await received.promise; + response.writeHead(status).end(`${token} ${ip} private-dataset`); + await Promise.all(pending); + expect(console.error).toHaveBeenCalledExactlyOnceWith( + `GoatCounter request rejected (HTTP ${status}).` + ); + expect(console.info).not.toHaveBeenCalled(); + } + ); + + it('distinguishes an ignored IP from an accepted pageview', async () => { + const received = await startEndpoint(); + visits(request(), context); + const { response } = await received.promise; + response.writeHead(202, { 'X-Goatcounter-Filter': '0' }).end(); + await Promise.all(pending); + expect(console.info).toHaveBeenCalledExactlyOnceWith( + 'GoatCounter visit filtered (HTTP 202).' + ); + }); + + it('contains network failures in the background task', async () => { + const received = await startEndpoint(); + visits(request(), context); + const { response } = await received.promise; + response.destroy(); + await expect(Promise.all(pending)).resolves.toEqual([undefined]); + expect(console.error).toHaveBeenCalledExactlyOnceWith( + 'GoatCounter request failed. Check configuration and network.' + ); + }); + + it.each(['GOATCOUNTER_SITE', 'GOATCOUNTER_API_TOKEN'])( + 'skips tracking when %s is unset', + (key) => { + delete env[key]; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + expect(visits(request(), context)).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + if (key === 'GOATCOUNTER_API_TOKEN') { + expect(console.warn).toHaveBeenCalledExactlyOnceWith( + 'GoatCounter disabled: GOATCOUNTER_API_TOKEN is not set.' + ); + } else { + expect(console.warn).not.toHaveBeenCalled(); + } + } + ); + + it.each(['HEAD', 'POST'])('does not count %s requests', (method) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + visits(new Request(request(), { method }), context); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('contains invalid configuration without interrupting the page', async () => { + env.GOATCOUNTER_SITE = 'invalid URL'; + expect(visits(request(), context)).toBeUndefined(); + await expect(Promise.all(pending)).resolves.toEqual([undefined]); + expect(console.error).toHaveBeenCalledExactlyOnceWith( + 'GoatCounter request failed. Check configuration and network.' + ); + }); +}); From 775dd9efd6d0247b1f1ca4244554ec51e764e9d1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 25 Oct 2026 09:52:47 -0400 Subject: [PATCH 2/3] chore: remove GoatCounter documentation and tests --- docs/deploying_volview.md | 29 ------ tests/visits.test.ts | 187 -------------------------------------- 2 files changed, 216 deletions(-) delete mode 100644 tests/visits.test.ts diff --git a/docs/deploying_volview.md b/docs/deploying_volview.md index 4371efbdb..3dcdfcfe7 100644 --- a/docs/deploying_volview.md +++ b/docs/deploying_volview.md @@ -11,35 +11,6 @@ As a local example, running `npx serve dist/` will spin up a static file server Please refer to your hosting provider's documentation on how to host a static site. You will need to upload the built files in `dist/` to wherever your provider specifies. -### Netlify visit analytics - -The `visits` Edge Function records GET requests to `/` using GoatCounter's -[backend API](https://www.goatcounter.com/help/backend). To enable it: - -1. In GoatCounter, open your username menu, then **API**, and create a token - with permission to count pageviews. -2. In Netlify, open **Project configuration > Environment variables** and set - `GOATCOUNTER_SITE` to your GoatCounter site URL (for example, - `https://volview.goatcounter.com`) and `GOATCOUNTER_API_TOKEN` to that token. - Both variables need the **Functions** scope. Set values only in the deploy - contexts you intend to track; leave previews and local development unset. - The token must remain server-side and must not have a `VITE_` prefix. -3. Redeploy. Edge Functions pick up environment variable changes on deployment. - Ensure Netlify Split Testing is disabled, since it prevents Edge Functions - from running. - -Deployments that already have `GOATCOUNTER_SITE` also need the API token. Without -it, the function skips tracking and logs a configuration warning. Without a site -URL, analytics stays disabled silently. - -The function uses `context.waitUntil()` to finish tracking after serving the page, -with a 10-second request timeout. Under **Cloud compute > Edge functions**, select -`visits` to see accepted or filtered pageviews, HTTP rejection statuses, and -network failures. Logs exclude tokens, visitor IP addresses, request URLs, and -response bodies. The function sends the host and path, referrer origin and path, -user agent, and visitor IP needed by GoatCounter for sessions and location; -query strings and fragments are omitted. - ## Self-hosted Server (nginx, apache2, etc.) Please refer to your desired server's documentation on how to serve static files. You will need to upload the built files in `dist/` to the static file directory on your server. Several examples are provided below. diff --git a/tests/visits.test.ts b/tests/visits.test.ts deleted file mode 100644 index afb459e4a..000000000 --- a/tests/visits.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// @vitest-environment node -import { once } from 'node:events'; -import { createServer, IncomingHttpHeaders, ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import type { Context } from '@netlify/edge-functions'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import visits from '../netlify/edge-functions/visits'; - -describe('Netlify visit analytics', () => { - const ip = '192.0.2.42'; - const token = 'test-token'; - let env: Record; - let pending: Promise[]; - let server: ReturnType | undefined; - - const request = () => - new Request( - 'https://volview.kitware.app/?urls=private-dataset#private-state', - { - headers: { - 'User-Agent': 'test-browser', - Referer: - 'https://example.com/viewer?token=private-token#private-state', - }, - } - ); - - const context = { - ip, - waitUntil: (task: Promise) => { - pending.push(task); - }, - } as Context; - - async function startEndpoint() { - const received = Promise.withResolvers<{ - method?: string; - url?: string; - headers: IncomingHttpHeaders; - body: unknown; - response: ServerResponse; - }>(); - server = createServer(async (req, response) => { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - received.resolve({ - method: req.method, - url: req.url, - headers: req.headers, - body: JSON.parse(Buffer.concat(chunks).toString()), - response, - }); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - env.GOATCOUNTER_SITE = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - return received; - } - - beforeEach(() => { - env = { - GOATCOUNTER_SITE: 'https://goatcounter.invalid', - GOATCOUNTER_API_TOKEN: token, - }; - pending = []; - vi.stubGlobal('Netlify', { env: { get: (key: string) => env[key] } }); - vi.spyOn(console, 'info').mockImplementation(() => {}); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(async () => { - if (server) { - server.closeAllConnections(); - await new Promise((resolve) => server!.close(() => resolve())); - server = undefined; - } - await Promise.all(pending); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('sends an authenticated pageview after allowing the page to continue', async () => { - const received = await startEndpoint(); - expect(visits(request(), context)).toBeUndefined(); - const { response, ...sent } = await received.promise; - - expect(sent).toMatchObject({ - method: 'POST', - url: '/api/v0/count', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - body: { - hits: [ - { - path: 'volview.kitware.app/', - title: 'VolView', - ref: 'https://example.com/viewer', - user_agent: 'test-browser', - ip, - }, - ], - }, - }); - expect(JSON.stringify(sent.body)).not.toContain('private-'); - expect(console.info).not.toHaveBeenCalled(); - expect(pending).toHaveLength(1); - - response.writeHead(202).end(); - await Promise.all(pending); - expect(console.info).toHaveBeenCalledExactlyOnceWith( - 'GoatCounter visit accepted (HTTP 202).' - ); - expect(console.error).not.toHaveBeenCalled(); - }); - - it.each([400, 401, 403, 429, 503])( - 'reports HTTP %i without logging the response body', - async (status) => { - const received = await startEndpoint(); - visits(request(), context); - const { response } = await received.promise; - response.writeHead(status).end(`${token} ${ip} private-dataset`); - await Promise.all(pending); - expect(console.error).toHaveBeenCalledExactlyOnceWith( - `GoatCounter request rejected (HTTP ${status}).` - ); - expect(console.info).not.toHaveBeenCalled(); - } - ); - - it('distinguishes an ignored IP from an accepted pageview', async () => { - const received = await startEndpoint(); - visits(request(), context); - const { response } = await received.promise; - response.writeHead(202, { 'X-Goatcounter-Filter': '0' }).end(); - await Promise.all(pending); - expect(console.info).toHaveBeenCalledExactlyOnceWith( - 'GoatCounter visit filtered (HTTP 202).' - ); - }); - - it('contains network failures in the background task', async () => { - const received = await startEndpoint(); - visits(request(), context); - const { response } = await received.promise; - response.destroy(); - await expect(Promise.all(pending)).resolves.toEqual([undefined]); - expect(console.error).toHaveBeenCalledExactlyOnceWith( - 'GoatCounter request failed. Check configuration and network.' - ); - }); - - it.each(['GOATCOUNTER_SITE', 'GOATCOUNTER_API_TOKEN'])( - 'skips tracking when %s is unset', - (key) => { - delete env[key]; - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - expect(visits(request(), context)).toBeUndefined(); - expect(fetchSpy).not.toHaveBeenCalled(); - if (key === 'GOATCOUNTER_API_TOKEN') { - expect(console.warn).toHaveBeenCalledExactlyOnceWith( - 'GoatCounter disabled: GOATCOUNTER_API_TOKEN is not set.' - ); - } else { - expect(console.warn).not.toHaveBeenCalled(); - } - } - ); - - it.each(['HEAD', 'POST'])('does not count %s requests', (method) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - visits(new Request(request(), { method }), context); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('contains invalid configuration without interrupting the page', async () => { - env.GOATCOUNTER_SITE = 'invalid URL'; - expect(visits(request(), context)).toBeUndefined(); - await expect(Promise.all(pending)).resolves.toEqual([undefined]); - expect(console.error).toHaveBeenCalledExactlyOnceWith( - 'GoatCounter request failed. Check configuration and network.' - ); - }); -}); From 9ffc78acf9472d0b840ad4a9622879d538a20852 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 25 Oct 2026 10:26:14 -0400 Subject: [PATCH 3/3] fix: preserve app referrers in visit analytics --- netlify/edge-functions/visits.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netlify/edge-functions/visits.ts b/netlify/edge-functions/visits.ts index b0dddc1c9..d980a40a4 100644 --- a/netlify/edge-functions/visits.ts +++ b/netlify/edge-functions/visits.ts @@ -6,7 +6,7 @@ function getReferrer(request: Request) { if (!referrer) return undefined; try { const url = new URL(referrer); - return `${url.origin}${url.pathname}`; + return `${url.protocol}//${url.host}${url.pathname}`; } catch { return undefined; } @@ -46,8 +46,6 @@ async function countVisit( console.error(`GoatCounter request rejected (HTTP ${response.status}).`); } else if (response.headers.has('X-Goatcounter-Filter')) { console.info('GoatCounter visit filtered (HTTP 202).'); - } else { - console.info('GoatCounter visit accepted (HTTP 202).'); } } catch { // Errors and response bodies may contain visitor data or credentials.