package edge import ( "context" "net" "net/http" "net/url" "strings" "time" "github.com/carlosframework/platform/internal/analytics" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // AnalyticsMeter is where the edge files one observation per response // for an app whose analytics are switched on (spec §3.3). analytics.Meter // satisfies it; the interface keeps the dependency on the contract. type AnalyticsMeter interface { Observe(analytics.Obs) } type analyticsMeterRef struct{ AnalyticsMeter } // EnableAnalytics wires the meter in; nil is a valid no-op. func (e *Edge) EnableAnalytics(m AnalyticsMeter) { if m == nil { e.analyticsMeter.Store(nil) return } e.analyticsMeter.Store(&analyticsMeterRef{m}) } // SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The // host's telemetry pass pushes it on every serial-triggered full pass and // every five minutes (spec §3.3, "converged by boxes on the next pass"). // The map is COPIED before it is stored, so a caller may keep and reuse // the map it passed: the stored copy is read by every serving goroutine // without a lock, and a caller that went on writing to a shared map would // turn that into Go's fatal concurrent map read-and-write — an edge-wide // crash, not a race one recovers from. func (e *Edge) SetAnalyticsOn(on map[string]bool) { if on == nil { e.analyticsOn.Store(nil) return } cp := make(map[string]bool, len(on)) for k, v := range on { cp[k] = v } e.analyticsOn.Store(&cp) } func (e *Edge) analyticsEnabled(rt registry.Route) bool { m := e.analyticsOn.Load() if m == nil { return false } return (*m)[release.RouteAccount(rt)+"/"+rt.App] } // withHopVerified marks ctx as a request that arrived over a proven mesh // hop, exactly as acceptHop does (homemap.go). Test-only in practice — // nothing on the serving path may put this mark on a request, which is the // whole property clientIP's X-Forwarded-For trust rests on. func withHopVerified(ctx context.Context) context.Context { return context.WithValue(ctx, hopVerifiedKey{}, true) } // clientIP is the address the visitor hash uses (spec §3.3): the last // element of the verified inbound X-Forwarded-For chain on a proven hop, // otherwise RemoteAddr's host. X-Forwarded-For is read ONLY under a proven // hop, whose mark nothing outside acceptHop can put on a request; on every // other request the header is ignored whether it is present or not, so a // header a client set can never reach the visitor hash. func clientIP(r *http.Request) string { if hopVerified(r.Context()) { chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") if i := strings.LastIndexByte(chain, ','); i >= 0 { chain = chain[i+1:] } if ip := strings.TrimSpace(chain); ip != "" { return ip } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // isPrefetchPurpose reports whether a Sec-Purpose (or legacy Purpose) // header value says this request is a prefetch. The value is a token list: // Chrome sends a bare "prefetch" for link prefetches and // "prefetch;prerender" for a speculation-rules prerender, which also // carries Sec-Fetch-Dest: document and so would otherwise be counted as a // pageview nobody looked at. Only the first token decides. func isPrefetchPurpose(v string) bool { first, _, _ := strings.Cut(v, ";") return strings.EqualFold(strings.TrimSpace(first), "prefetch") } // observeAnalytics files one Obs when the route's app is switched on. It // runs inside the observer defer in ServeHTTP, after the response has // completed, and reads only: the latched status and Content-Type off the // counting writer, the byte count, and the request's own headers. No // wrapper is added for it and no path here can wake an instance. func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) { ref := e.analyticsMeter.Load() if ref == nil || !e.analyticsEnabled(rt) { return } // One observation per served response (geoip spec §9, codex R4-1): the // hub that forwarded this over the tunnel already observed it, from its // own cache hit or from this response. if fromTunnel(r.Context()) { return } geoAns, _ := geoAnswerFrom(r.Context()) refHost := "" if u, err := url.Parse(r.Header.Get("Referer")); err == nil { refHost = u.Hostname() } prefetch := isPrefetchPurpose(r.Header.Get("Sec-Purpose")) || isPrefetchPurpose(r.Header.Get("Purpose")) ref.Observe(analytics.Obs{ Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host, Method: r.Method, Status: ob.status, ContentType: ob.ctype, Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"), ClientIP: clientIP(r), Bytes: bytes, Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")), When: time.Now(), Country: geoAns.Country, GeoSource: geoAns.Source, GeoState: string(geoAns.State), }) }