Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ SUPERMETRY_HTTP_WRITE_TIMEOUT=60s
# --- Tracking (rename paths to dodge ad-blockers) ---
SUPERMETRY_EVENT_PATH=/api/public/v1/events
SUPERMETRY_TRACKER_PATH=/js/script.js
# Per-site scripts loaded from SUPERMETRY_BASE_URL send to /e/<site> paths. false keeps the paths above.
# SUPERMETRY_NEUTRAL_PATHS=true
# Exact operator-reviewed public path literals only; no globs, IDs, or secrets.
# Absent or [] denies all anonymous paths. Changes require a coordinated restart.
# Example: SUPERMETRY_TRACKING_PUBLIC_PATHS='["/","/pricing"]'
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Supermetry is a self-hosted web analytics product: one Go binary and PostgreSQL,

## Unreleased

### Added

- Neutral per-site intake paths to get past ad blocker rules for analytics URLs. Events go to `POST /e/<site>`. Replay uses `/e/<site>/r.js` for the recorder, `/e/<site>/s` to start, `/e/<site>/c` for chunks and `/e/<site>/k/<hash>` for stylesheets. A site's script uses them when it is loaded straight from `SUPERMETRY_BASE_URL`, and keeps the classic paths behind a first-party proxy. Requests whose body or `?s=` names another site are rejected. The classic paths are unchanged. `SUPERMETRY_NEUTRAL_PATHS=false` turns this off.

### Changed

- The tracker's lazy-module loader requests `?m=v,h,r` instead of `?module=vitals,heatmap,replay`. The long form is still accepted.

## v0.1.19 (2026-10-28)

### Changed
Expand Down
5 changes: 5 additions & 0 deletions cmd/supermetry/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,11 @@ func classifySecurityRoute(cfg config.Config, requestPath string) securityRouteC
if requestPath == "/replay.js" || isTrackerPath(cfg.TrackerPath, requestPath) {
return securityRouteTracker
}
if intake, recorder := api.NeutralPathKind(requestPath); intake {
return securityRouteCrossOriginIntake
} else if recorder {
return securityRouteTracker
}
if strings.HasPrefix(requestPath, "/share/") {
return securityRouteShare
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/supermetry/serve_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ func TestSecurityHeadersByRouteClass(t *testing.T) {
{name: "tracker event", path: "/api/public/v1/events", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "replay chunk", path: "/api/replay/chunk", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "replay CSS", path: "/api/replay/css/abc", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "neutral event", path: "/e/abc123", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "neutral replay start", path: "/e/abc123/s", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "neutral replay CSS", path: "/e/abc123/k/abc", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "neutral recorder", path: "/e/abc123/r.js", want: with(map[string]string{"Cross-Origin-Resource-Policy": "cross-origin"})},
{name: "health", path: "/healthz", want: with(map[string]string{"Content-Security-Policy": apiCSP, "X-Frame-Options": "DENY", "Cross-Origin-Resource-Policy": "same-origin"})},
{name: "metrics", path: "/metrics", want: with(map[string]string{"Content-Security-Policy": apiCSP, "X-Frame-Options": "DENY", "Cross-Origin-Resource-Policy": "same-origin"})},
{name: "share-link page", path: "/share/shr_example", want: with(map[string]string{"Content-Security-Policy": shareCSP, "Permissions-Policy": appPermissions, "X-Frame-Options": "DENY", "Cross-Origin-Resource-Policy": "same-origin"})},
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,27 @@ To check the setup, set `SUPERMETRY_INGEST_DIAG=1` (below) and look at `peer_tru
|---|---|---|---|
| `SUPERMETRY_EVENT_PATH` | URL path the tracker posts events to. Must start with `/`. | `/api/public/v1/events` | `/a/e` |
| `SUPERMETRY_TRACKER_PATH` | URL path the tracker script is served from. Must start with `/`. | `/js/script.js` | `/a/t.js` |
| `SUPERMETRY_NEUTRAL_PATHS` | Per-site scripts loaded straight from `SUPERMETRY_BASE_URL` send events and replay to the site's own `/e/<site>` paths (see below). `false` keeps the classic paths. The `/e/` routes are served either way. | `true` | `false` |
| `SUPERMETRY_TRACKING_MODE` | `identified` collects real page paths, referrers and campaigns, and new projects start with profiles on. `anonymous` is the strictest mode. See [Tracking and privacy](../tracking-privacy.md). Changing it needs a restart. | `identified` | `anonymous` |

Ad blockers match on well-known names such as `script.js` or `/events`. Changing both paths to neutral values avoids those filters. The tracking snippet shown in the dashboard is built from `SUPERMETRY_BASE_URL` plus these paths, so reinstall the snippet on your sites after changing them. Whether the old paths keep working after a change: see source (`internal/api/router.go`). If a proxy routes only specific paths to Supermetry, update it to match.

### Neutral per-site paths

Each site also has its own intake paths under `/e/<site public ID>`. None of them contains a word that ad blocker rules look for:

| Classic path | Neutral path |
|---|---|
| `POST /api/public/v1/events` (`SUPERMETRY_EVENT_PATH`) | `POST /e/<site>` |
| `GET /replay.js` | `GET /e/<site>/r.js` |
| `POST /api/replay/start` | `POST /e/<site>/s` |
| `POST /api/replay/chunk?s=<site>` | `POST /e/<site>/c?s=<site>` |
| `PUT /api/replay/css/<hash>?s=<site>` | `PUT /e/<site>/k/<hash>?s=<site>` |

A site's script (`/js/<site>.js`) uses the neutral paths when it is loaded straight from `SUPERMETRY_BASE_URL`. When it is loaded from any other origin, such as a first-party proxy that forwards only the classic paths, it keeps the classic paths, so existing proxies keep working. The script names its lazily loaded modules by short code (`?m=v,h,r`), so its URL never contains `replay` or `heatmap`. The server rejects a request whose body or `?s=` names a site other than the one in the path. The classic paths stay available.

Neutral paths do not help when a filter list blocks the Supermetry host itself. Only a first-party proxy on your own domain avoids that.

### Referrer spam list

Supermetry ships a list of referrer-spam domains that changes only with a release. To add your own domains without waiting for one, set `SUPERMETRY_REFERRER_SPAM_FILE` to a text file with one hostname per line (blank lines and lines starting with `#` are ignored; no inline comments). The file is limited to 1 MiB. It is validated as a whole: one invalid line rejects the file and the server refuses to start. It is read at startup only, so restart Supermetry after changing it. A listed domain also blocks its subdomains (`evil.example` blocks `a.evil.example`, not `notevil.example`).
Expand Down
155 changes: 155 additions & 0 deletions internal/api/neutral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package api

import (
"net/http"
"strconv"
"strings"

"supermetry/internal/ingest"
"supermetry/internal/replay"
)

// Neutral intake paths give every site its own URLs under /e/{site}, so ad
// blocker rules written for well-known analytics paths (/api/.../events,
// /replay.js, /api/replay/...) do not match. Each one is an alias that
// rewrites the request to the legacy path and hands it to the legacy handler,
// which also rejects a request whose body or query names another site.
//
// POST /e/{site} events
// GET /e/{site}/r.js replay recorder
// POST /e/{site}/s replay start
// POST /e/{site}/c?s=... replay chunk
// PUT /e/{site}/k/{hash} replay stylesheet
const neutralPrefix = "/e/"

// NeutralPathKind reports whether p is a neutral intake path (events or
// replay uploads) or the neutral replay recorder script.
func NeutralPathKind(p string) (intake, recorder bool) {
_, rest, ok := splitNeutralPath(p)
if !ok {
return false, false
}
switch {
case rest == "", rest == "s", rest == "c":
return true, false
case rest == "r.js":
return false, true
case strings.HasPrefix(rest, "k/") && validNeutralSegment(rest[2:]):
return true, false
}
return false, false
}

// splitNeutralPath splits /e/{site}[/{rest}] and validates the site segment.
func splitNeutralPath(p string) (site, rest string, ok bool) {
tail, found := strings.CutPrefix(p, neutralPrefix)
if !found {
return "", "", false
}
site, rest, hasRest := strings.Cut(tail, "/")
if !validNeutralSegment(site) || hasRest && rest == "" {
return "", "", false
}
return site, rest, true
}

func validNeutralSegment(value string) bool {
if value == "" || len(value) > 64 {
return false
}
for _, c := range value {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}

type neutralDeps struct {
ingest http.Handler // already wrapped in trackerCORS; nil when ingest is off
eventPath string
replay http.Handler // start and chunk; nil when replay is off
replayCSS http.Handler
recorder http.Handler
replayEnabled bool
}

func neutralHandler(deps neutralDeps) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
site, rest, ok := splitNeutralPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
replayOn := deps.replayEnabled && deps.replay != nil
switch {
case rest == "":
if deps.ingest == nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost && r.Method != http.MethodOptions {
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
deps.ingest.ServeHTTP(w, neutralRequest(r, site, deps.eventPath))
case rest == "s" && replayOn:
deps.replay.ServeHTTP(w, neutralRequest(r, site, "/api/replay/start"))
case rest == "c" && replayOn:
if !neutralQuerySite(w, r, site) {
return
}
deps.replay.ServeHTTP(w, neutralRequest(r, site, "/api/replay/chunk"))
case strings.HasPrefix(rest, "k/") && validNeutralSegment(rest[2:]) && deps.replayEnabled && deps.replayCSS != nil:
if !neutralQuerySite(w, r, site) {
return
}
deps.replayCSS.ServeHTTP(w, neutralRequest(r, site, "/api/replay/css/"+rest[2:]))
case rest == "r.js" && deps.replayEnabled:
deps.recorder.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}

// neutralQuerySite checks that a replay upload's ?s= names the path's site.
// Preflights carry no query worth checking.
func neutralQuerySite(w http.ResponseWriter, r *http.Request, site string) bool {
if r.Method == http.MethodOptions || r.URL.Query().Get("s") == site {
return true
}
http.Error(w, "site does not match path", http.StatusBadRequest)
return false
}

func neutralRequest(r *http.Request, site, legacyPath string) *http.Request {
clone := r.Clone(ingest.WithPathSite(r.Context(), site))
clone.URL.Path = legacyPath
clone.URL.RawPath = ""
return clone
}

// recorderHandler serves the replay recorder script (/replay.js and its
// neutral alias).
func recorderHandler(enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if !enabled {
http.NotFound(w, request)
return
}
body, etag := replay.ReplayJS()
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("ETag", etag)
if request.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if request.Method != http.MethodHead {
_, _ = w.Write(body)
}
})
}
154 changes: 154 additions & 0 deletions internal/api/neutral_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package api_test

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"supermetry/internal/api"
"supermetry/internal/config"
"supermetry/internal/ingest"
)

type seenRequest struct {
path, site string
called bool
}

func recordingHandler(seen *seenRequest) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen.called = true
seen.path = r.URL.Path
seen.site, _ = ingest.PathSite(r.Context())
w.WriteHeader(http.StatusAccepted)
})
}

func neutralRouter(replayEnabled bool) (http.Handler, *seenRequest, *seenRequest, *seenRequest) {
events, replay, css := &seenRequest{}, &seenRequest{}, &seenRequest{}
cfg := config.Config{EventPath: "/api/public/v1/events", AdminAPIKey: "test-key"}
cfg.Replay.Enabled = replayEnabled
router := api.NewRouter(api.Deps{
Config: cfg,
Ingest: recordingHandler(events),
Replay: recordingHandler(replay),
ReplayCSS: recordingHandler(css),
})
return router, events, replay, css
}

func TestNeutralPathsReachLegacyHandlers(t *testing.T) {
router, events, replay, css := neutralRouter(true)
tests := []struct {
name, method, target string
seen *seenRequest
wantPath, wantSite string
}{
{"events", http.MethodPost, "/e/abc123", events, "/api/public/v1/events", "abc123"},
{"replay start", http.MethodPost, "/e/abc123/s", replay, "/api/replay/start", "abc123"},
{"replay chunk", http.MethodPost, "/e/abc123/c?s=abc123&q=1", replay, "/api/replay/chunk", "abc123"},
{"replay css", http.MethodPut, "/e/abc123/k/0123abcd?s=abc123", css, "/api/replay/css/0123abcd", "abc123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
*tt.seen = seenRequest{}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(tt.method, tt.target, strings.NewReader("{}")))
if response.Code != http.StatusAccepted || !tt.seen.called {
t.Fatalf("status %d, called %v", response.Code, tt.seen.called)
}
if tt.seen.path != tt.wantPath || tt.seen.site != tt.wantSite {
t.Fatalf("handler saw path %q site %q, want %q %q", tt.seen.path, tt.seen.site, tt.wantPath, tt.wantSite)
}
})
}
}

func TestNeutralEventsPreflightAllowsCrossOrigin(t *testing.T) {
router, events, _, _ := neutralRouter(false)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodOptions, "/e/abc123", nil))
if response.Code != http.StatusNoContent || response.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("preflight status %d, headers %v", response.Code, response.Header())
}
if events.called {
t.Fatal("preflight reached the ingest handler")
}
}

func TestNeutralPathsRejectMismatchedOrUnknownRoutes(t *testing.T) {
router, events, replay, css := neutralRouter(true)
tests := []struct {
name, method, target string
want int
}{
{"chunk site mismatch", http.MethodPost, "/e/abc123/c?s=other", http.StatusBadRequest},
{"chunk site missing", http.MethodPost, "/e/abc123/c", http.StatusBadRequest},
{"css site mismatch", http.MethodPut, "/e/abc123/k/0123abcd?s=other", http.StatusBadRequest},
{"unknown sub-path", http.MethodPost, "/e/abc123/zzz", http.StatusNotFound},
{"trailing slash", http.MethodPost, "/e/abc123/", http.StatusNotFound},
{"bad id", http.MethodPost, "/e/a.b", http.StatusNotFound},
{"events wrong method", http.MethodGet, "/e/abc123", http.StatusMethodNotAllowed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
*events, *replay, *css = seenRequest{}, seenRequest{}, seenRequest{}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(tt.method, tt.target, strings.NewReader("{}")))
if response.Code != tt.want {
t.Fatalf("status %d, want %d", response.Code, tt.want)
}
if events.called || replay.called || css.called {
t.Fatal("request reached a handler")
}
})
}
}

func TestNeutralReplayRoutesOffWhenReplayDisabled(t *testing.T) {
router, _, replay, _ := neutralRouter(false)
for _, target := range []string{"/e/abc123/s", "/e/abc123/r.js"} {
method := http.MethodPost
if strings.HasSuffix(target, ".js") {
method = http.MethodGet
}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(method, target, nil))
if response.Code != http.StatusNotFound || replay.called {
t.Fatalf("%s: status %d, called %v", target, response.Code, replay.called)
}
}
}

func TestNeutralRecorderServesReplayScript(t *testing.T) {
router, _, _, _ := neutralRouter(true)
legacy, neutral := httptest.NewRecorder(), httptest.NewRecorder()
router.ServeHTTP(legacy, httptest.NewRequest(http.MethodGet, "/replay.js", nil))
router.ServeHTTP(neutral, httptest.NewRequest(http.MethodGet, "/e/abc123/r.js", nil))
if neutral.Code != http.StatusOK || neutral.Body.String() != legacy.Body.String() || neutral.Header().Get("ETag") != legacy.Header().Get("ETag") {
t.Fatalf("neutral recorder status %d, same body %v", neutral.Code, neutral.Body.String() == legacy.Body.String())
}
}

func TestNeutralPathClassification(t *testing.T) {
tests := []struct {
path string
intake, recorder bool
}{
{"/e/abc123", true, false},
{"/e/abc123/s", true, false},
{"/e/abc123/c", true, false},
{"/e/abc123/k/0123abcd", true, false},
{"/e/abc123/r.js", false, true},
{"/e/abc123/zzz", false, false},
{"/e/", false, false},
{"/events", false, false},
}
for _, tt := range tests {
intake, recorder := api.NeutralPathKind(tt.path)
if intake != tt.intake || recorder != tt.recorder {
t.Errorf("%s: intake %v recorder %v, want %v %v", tt.path, intake, recorder, tt.intake, tt.recorder)
}
}
}
Loading
Loading