Skip to content

Commit 2e342f7

Browse files
committed
Analytics: Serve the Umami tracker first-party so adblockers stop hiding our traffic
gitstrata.com loaded the tracker straight from a third-party analytics domain at the well-known `/script.js` path — both on adblock blocklists, so most visitors went uncounted. A Cloudflare Pages Function now proxies it same-origin under `/u/*`, matching the first-party setup on the other sites. - Add `functions/u/[[path]].ts`: proxies `/u/mami` → upstream `/script.js` and passes the beacon (`/u/api/send`) plus everything else straight through. Forwards the real visitor IP via `x-forwarded-for` so Umami still counts uniques; caches the script briefly, never the beacon. Upstream host defaults to our Umami instance, overridable with the `ANALYTICS_UPSTREAM` Pages env var for self-hosters. - `+layout.svelte`: load the tracker from same-origin `/u/mami` (was `{PUBLIC_ANALYTICS_URL}/script.js`). The tracker derives its beacon endpoint from its own script directory, so it posts to `/u/api/send`. - Gate the tag on `PUBLIC_ANALYTICS_ID` alone; the upstream URL now lives in the Function, so drop `PUBLIC_ANALYTICS_URL` from the frontend and the CI build env. - Docs: add `functions/CLAUDE.md`; update architecture, deploying, AGENTS, and `.env.example`. - Ignore the local `.wrangler/` dir that `wrangler pages dev` creates.
1 parent 8d0a3f5 commit 2e342f7

9 files changed

Lines changed: 106 additions & 18 deletions

File tree

.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ PUBLIC_CORS_PROXY_URL=https://cors.isomorphic-git.org
66
# Shared results cache — set to your CORS proxy URL after enabling R2 (see docs/deploying.md step 3).
77
# PUBLIC_SHARED_CACHE_URL=https://your-proxy.workers.dev
88

9-
# Optional Umami analytics. Both are required to enable tracking.
10-
# PUBLIC_ANALYTICS_URL=https://umami.yourdomain.com
9+
# Optional Umami analytics. Set the website ID to enable tracking. The tracker loads first-party
10+
# through the Pages Function at /u/* (see functions/CLAUDE.md); point that at your Umami host with
11+
# the ANALYTICS_UPSTREAM environment variable on the Pages project, not here.
1112
# PUBLIC_ANALYTICS_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
frontend:
2929
- 'src/**'
3030
- 'static/**'
31+
- 'functions/**'
3132
- 'package.json'
3233
- 'pnpm-lock.yaml'
3334
- 'svelte.config.js'
@@ -153,7 +154,6 @@ jobs:
153154
env:
154155
PUBLIC_CORS_PROXY_URL: https://proxy.gitstrata.com
155156
PUBLIC_CACHE_WRITE_TOKEN: 1938d93de2ba8ed03f53069e4216a9ddd90af40b692a4d016b46ec28ecf3f19e
156-
PUBLIC_ANALYTICS_URL: https://anal.veszelovszki.com
157157
PUBLIC_ANALYTICS_ID: 9c082669-20f5-486f-85b8-13911ded8e14
158158

159159
- run: npx wrangler pages deploy build/ --project-name=git-strata

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ scripts/loc-counter/
1010
coverage/
1111
*.log
1212
.DS_Store
13+
.wrangler/
1314
cors-proxy/.wrangler/
1415
cors-proxy/.dev.vars
1516
_ignored/

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Core structure:
2727
- `lib/types.ts` - Shared type definitions
2828
- `app.css` - Tailwind v4 import + CSS custom properties
2929
- `cors-proxy/` - Cloudflare Worker (Hono) — adds CORS headers, forwards bytes, zero compute
30+
- `functions/` - Cloudflare Pages Functions (same-origin as the frontend). `u/[[path]]` proxies the Umami tracker
31+
first-party (`/u/*`) so adblockers don't strip it. See `functions/CLAUDE.md`.
3032
- `scripts/` - Go-based check runner (`scripts/check/`) and reference LoC counter (read-only)
3133
- `static/` - Static assets
3234
- `tests/` - Unit tests (Vitest) and end-to-end tests (Playwright)

docs/architecture.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ Details: [`cors-proxy/CLAUDE.md`](../cors-proxy/CLAUDE.md)
3434
| Ref caching | Caches v1 `/info/refs` responses at the edge (12 h TTL) |
3535
| Shared results cache | Optional R2-backed cache for analysis results (`GET`/`PUT /cache/v1/:repoHash`) |
3636

37+
## Pages Functions (`functions/`)
38+
39+
Details: [`functions/CLAUDE.md`](../functions/CLAUDE.md)
40+
41+
| Component | Purpose |
42+
| ------------ | --------------------------------------------------------------------------------------------------------- |
43+
| `u/[[path]]` | Same-origin proxy for the Umami tracker (`/u/mami``/script.js`, beacon → `/api/send`) to dodge adblock |
44+
3745
## Tooling
3846

3947
| Directory | Purpose |

docs/deploying.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,25 @@ Once these are set, every push to `main` that passes CI will auto-deploy both th
134134

135135
## Environment variables
136136

137-
| Variable | Where | Default | Purpose |
138-
| -------------------------- | ---------------------------- | --------------------------------- | --------------------------------------------------------------- |
139-
| `PUBLIC_CORS_PROXY_URL` | Frontend (`.env`) | `https://cors.isomorphic-git.org` | URL of your deployed CORS proxy |
140-
| `PUBLIC_SHARED_CACHE_URL` | Frontend (`.env`) | _(none)_ | URL of shared cache API (same as CORS proxy when R2 is enabled) |
141-
| `PUBLIC_CACHE_WRITE_TOKEN` | Frontend (`.env` / `ci.yml`) | _(none)_ | Bearer token for cache writes (must match worker secret below) |
142-
| `ALLOWED_ORIGIN` | Worker (`wrangler.toml`) | `https://gitstrata.com` | CORS origin restriction — override in `.dev.vars` for local dev |
143-
| `CACHE_WRITE_TOKEN` | Worker secret / `.dev.vars` | _(none)_ | Server-side token that cache PUT requests are checked against |
144-
| `PUBLIC_ANALYTICS_URL` | Frontend (`.env`) | _(none)_ | Umami server URL (for example, `https://umami.yourdomain.com`) |
145-
| `PUBLIC_ANALYTICS_ID` | Frontend (`.env`) | _(none)_ | Umami website ID (UUID from your Umami dashboard) |
137+
| Variable | Where | Default | Purpose |
138+
| -------------------------- | ---------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
139+
| `PUBLIC_CORS_PROXY_URL` | Frontend (`.env`) | `https://cors.isomorphic-git.org` | URL of your deployed CORS proxy |
140+
| `PUBLIC_SHARED_CACHE_URL` | Frontend (`.env`) | _(none)_ | URL of shared cache API (same as CORS proxy when R2 is enabled) |
141+
| `PUBLIC_CACHE_WRITE_TOKEN` | Frontend (`.env` / `ci.yml`) | _(none)_ | Bearer token for cache writes (must match worker secret below) |
142+
| `ALLOWED_ORIGIN` | Worker (`wrangler.toml`) | `https://gitstrata.com` | CORS origin restriction — override in `.dev.vars` for local dev |
143+
| `CACHE_WRITE_TOKEN` | Worker secret / `.dev.vars` | _(none)_ | Server-side token that cache PUT requests are checked against |
144+
| `PUBLIC_ANALYTICS_ID` | Frontend (`.env`) | _(none)_ | Umami website ID (UUID from your Umami dashboard) — set to enable tracking |
145+
| `ANALYTICS_UPSTREAM` | Pages Function (runtime var) | git strata's Umami instance | Your Umami server URL that `/u/*` proxies to (for example, `https://umami.yourdomain.com`) |
146146
147147
`PUBLIC_CACHE_WRITE_TOKEN` and `CACHE_WRITE_TOKEN` must have the same value. The frontend token is hard-coded in
148148
`ci.yml` for production builds. The worker token is set via `wrangler secret put` (production) or `cors-proxy/.dev.vars`
149149
(local dev).
150150
151-
Both `PUBLIC_ANALYTICS_URL` and `PUBLIC_ANALYTICS_ID` must be set to enable analytics. When either is missing, no
152-
tracking script is loaded.
151+
Set `PUBLIC_ANALYTICS_ID` to enable analytics. When it's missing, no tracking script is loaded. The tracker loads
152+
first-party from the Pages Function at `/u/*` (see `functions/CLAUDE.md`) so adblockers don't strip it — this dodges the
153+
third-party analytics domain and the well-known `/script.js` filename, both on adblock blocklists. Set
154+
`ANALYTICS_UPSTREAM` on the Pages project (Cloudflare dashboard > **Settings > Environment variables**) to point `/u/*`
155+
at your own Umami host.
153156
154157
Frontend variables are set at build time in CI (see the `deploy-frontend` job in `ci.yml`). For local development,
155158
create a `.env` file in the repo root:
@@ -159,7 +162,6 @@ PUBLIC_CORS_PROXY_URL=https://your-proxy.workers.dev
159162
# PUBLIC_SHARED_CACHE_URL=https://your-proxy.workers.dev
160163
PUBLIC_CACHE_WRITE_TOKEN=your-token-here
161164
# PUBLIC_ANALYTICS_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
162-
# PUBLIC_ANALYTICS_URL=https://umami.yourdomain.com
163165
```
164166
165167
For local proxy dev, create `cors-proxy/.dev.vars`:

functions/CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Pages Functions
2+
3+
Cloudflare Pages Functions that run alongside the static frontend on the same origin (gitstrata.com). Wrangler discovers
4+
this `functions/` directory at deploy time (`wrangler pages deploy build/`, run from the repo root) and compiles it into
5+
a Worker that fronts the static assets. Routes not matched by a function fall through to the static site.
6+
7+
## Analytics proxy (`u/[[path]].ts`)
8+
9+
Same-origin proxy for the Umami analytics tracker, matching `/u/*`.
10+
11+
- **Why it exists**: Loading the tracker directly from a third-party analytics domain at the well-known `/script.js`
12+
path gets it blocked by adblockers on both counts, so most visitors go uncounted. Serving it first-party under `/u/*`
13+
sidesteps the blocklists.
14+
- **Path mapping**: `/u/mami` → upstream `/script.js` (the only path Umami serves the tracker at). Everything else maps
15+
straight through, notably the beacon: the tracker derives its collect endpoint from its own script directory, so with
16+
`src="/u/mami"` it posts to `/u/api/send`, which proxies to upstream `/api/send`. The `data-website-id` on the script
17+
tag (`src/routes/+layout.svelte`) is what ties events to the right site.
18+
- **Upstream host**: defaults to git strata's own Umami instance. Self-hosters override it with the `ANALYTICS_UPSTREAM`
19+
environment variable on the Pages project (Cloudflare dashboard > Settings > Environment variables, or a Pages
20+
`wrangler.toml` `[vars]`).
21+
- **Caching**: the tracker script gets `Cache-Control: public, max-age=3600`; the beacon (and any other path) gets
22+
`no-store`.
23+
- **Client IP**: the real visitor IP (`cf-connecting-ip`) is forwarded as `x-forwarded-for` so Umami counts uniques
24+
correctly instead of attributing every hit to the proxy.

functions/u/[[path]].ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Same-origin proxy for the Umami analytics tracker.
2+
//
3+
// Loading the tracker first-party under `/u/*` keeps it off adblock blocklists, which flag both
4+
// third-party analytics domains and the well-known `/script.js` filename — a directly-loaded
5+
// tracker gets blocked and most visitors go uncounted. `/u/mami` maps to the analytics host's
6+
// `/script.js` (the only path Umami serves the tracker at); the tracker derives its beacon
7+
// endpoint from its own directory, so it posts to `/u/api/send`, which maps straight through to
8+
// the host's `/api/send`.
9+
//
10+
// The upstream host defaults to git strata's own Umami instance; self-hosters override it with the
11+
// `ANALYTICS_UPSTREAM` environment variable on the Pages project (see docs/deploying.md).
12+
13+
interface Env {
14+
ANALYTICS_UPSTREAM?: string
15+
}
16+
17+
const defaultUpstream = 'https://anal.veszelovszki.com'
18+
19+
export const onRequest = async (context: { request: Request; env: Env }): Promise<Response> => {
20+
const upstream = (context.env.ANALYTICS_UPSTREAM ?? defaultUpstream).replace(/\/+$/, '')
21+
const requestUrl = new URL(context.request.url)
22+
const subPath = requestUrl.pathname.replace(/^\/u\//, '')
23+
const upstreamPath = subPath === 'mami' ? 'script.js' : subPath
24+
const targetUrl = `${upstream}/${upstreamPath}${requestUrl.search}`
25+
26+
const headers = new Headers(context.request.headers)
27+
headers.delete('host')
28+
const clientIp = context.request.headers.get('cf-connecting-ip')
29+
if (clientIp) {
30+
// Preserve the real visitor IP so Umami counts uniques correctly instead of seeing the proxy.
31+
headers.set('x-forwarded-for', clientIp)
32+
}
33+
34+
const method = context.request.method
35+
const hasBody = method !== 'GET' && method !== 'HEAD'
36+
const upstreamResponse = await fetch(targetUrl, {
37+
method,
38+
headers,
39+
body: hasBody ? await context.request.arrayBuffer() : undefined,
40+
})
41+
42+
const responseHeaders = new Headers(upstreamResponse.headers)
43+
// The tracker script is safe to cache briefly; the beacon must never be cached.
44+
responseHeaders.set('cache-control', upstreamPath === 'script.js' ? 'public, max-age=3600' : 'no-store')
45+
46+
return new Response(upstreamResponse.body, {
47+
status: upstreamResponse.status,
48+
statusText: upstreamResponse.statusText,
49+
headers: responseHeaders,
50+
})
51+
}

src/routes/+layout.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010
let { children } = $props()
1111
1212
const analyticsId = env.PUBLIC_ANALYTICS_ID
13-
const analyticsUrl = env.PUBLIC_ANALYTICS_URL
1413
</script>
1514

1615
<svelte:head>
17-
{#if analyticsId && analyticsUrl}
18-
<script defer data-website-id={analyticsId} src="{analyticsUrl}/script.js"></script>
16+
{#if analyticsId}
17+
<script defer data-website-id={analyticsId} src="/u/mami"></script>
1918
{/if}
2019
</svelte:head>
2120

0 commit comments

Comments
 (0)