| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/analytics" |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // AnalyticsMeter is where the edge files one observation per response |
| 17 | // for an app whose analytics are switched on (spec §3.3). analytics.Meter |
| 18 | // satisfies it; the interface keeps the dependency on the contract. |
| 19 | type AnalyticsMeter interface { |
| 20 | Observe(analytics.Obs) |
| 21 | } |
| 22 | |
| 23 | type analyticsMeterRef struct{ AnalyticsMeter } |
| 24 | |
| 25 | // EnableAnalytics wires the meter in; nil is a valid no-op. |
| 26 | func (e *Edge) EnableAnalytics(m AnalyticsMeter) { |
| 27 | if m == nil { |
| 28 | e.analyticsMeter.Store(nil) |
| 29 | return |
| 30 | } |
| 31 | e.analyticsMeter.Store(&analyticsMeterRef{m}) |
| 32 | } |
| 33 | |
| 34 | // SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The |
| 35 | // host's telemetry pass pushes it on every serial-triggered full pass and |
| 36 | // every five minutes (spec §3.3, "converged by boxes on the next pass"). |
| 37 | // The map is COPIED before it is stored, so a caller may keep and reuse |
| 38 | // the map it passed: the stored copy is read by every serving goroutine |
| 39 | // without a lock, and a caller that went on writing to a shared map would |
| 40 | // turn that into Go's fatal concurrent map read-and-write — an edge-wide |
| 41 | // crash, not a race one recovers from. |
| 42 | func (e *Edge) SetAnalyticsOn(on map[string]bool) { |
| 43 | if on == nil { |
| 44 | e.analyticsOn.Store(nil) |
| 45 | return |
| 46 | } |
| 47 | cp := make(map[string]bool, len(on)) |
| 48 | for k, v := range on { |
| 49 | cp[k] = v |
| 50 | } |
| 51 | e.analyticsOn.Store(&cp) |
| 52 | } |
| 53 | |
| 54 | func (e *Edge) analyticsEnabled(rt registry.Route) bool { |
| 55 | m := e.analyticsOn.Load() |
| 56 | if m == nil { |
| 57 | return false |
| 58 | } |
| 59 | return (*m)[release.RouteAccount(rt)+"/"+rt.App] |
| 60 | } |
| 61 | |
| 62 | // withHopVerified marks ctx as a request that arrived over a proven mesh |
| 63 | // hop, exactly as acceptHop does (homemap.go). Test-only in practice — |
| 64 | // nothing on the serving path may put this mark on a request, which is the |
| 65 | // whole property clientIP's X-Forwarded-For trust rests on. |
| 66 | func withHopVerified(ctx context.Context) context.Context { |
| 67 | return context.WithValue(ctx, hopVerifiedKey{}, true) |
| 68 | } |
| 69 | |
| 70 | // clientIP is the address the visitor hash uses (spec §3.3): the last |
| 71 | // element of the verified inbound X-Forwarded-For chain on a proven hop, |
| 72 | // otherwise RemoteAddr's host. X-Forwarded-For is read ONLY under a proven |
| 73 | // hop, whose mark nothing outside acceptHop can put on a request; on every |
| 74 | // other request the header is ignored whether it is present or not, so a |
| 75 | // header a client set can never reach the visitor hash. |
| 76 | func clientIP(r *http.Request) string { |
| 77 | if hopVerified(r.Context()) { |
| 78 | chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| 79 | if i := strings.LastIndexByte(chain, ','); i >= 0 { |
| 80 | chain = chain[i+1:] |
| 81 | } |
| 82 | if ip := strings.TrimSpace(chain); ip != "" { |
| 83 | return ip |
| 84 | } |
| 85 | } |
| 86 | host, _, err := net.SplitHostPort(r.RemoteAddr) |
| 87 | if err != nil { |
| 88 | return r.RemoteAddr |
| 89 | } |
| 90 | return host |
| 91 | } |
| 92 | |
| 93 | // isPrefetchPurpose reports whether a Sec-Purpose (or legacy Purpose) |
| 94 | // header value says this request is a prefetch. The value is a token list: |
| 95 | // Chrome sends a bare "prefetch" for link prefetches and |
| 96 | // "prefetch;prerender" for a speculation-rules prerender, which also |
| 97 | // carries Sec-Fetch-Dest: document and so would otherwise be counted as a |
| 98 | // pageview nobody looked at. Only the first token decides. |
| 99 | func isPrefetchPurpose(v string) bool { |
| 100 | first, _, _ := strings.Cut(v, ";") |
| 101 | return strings.EqualFold(strings.TrimSpace(first), "prefetch") |
| 102 | } |
| 103 | |
| 104 | // observeAnalytics files one Obs when the route's app is switched on. It |
| 105 | // runs inside the observer defer in ServeHTTP, after the response has |
| 106 | // completed, and reads only: the latched status and Content-Type off the |
| 107 | // counting writer, the byte count, and the request's own headers. No |
| 108 | // wrapper is added for it and no path here can wake an instance. |
| 109 | func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) { |
| 110 | ref := e.analyticsMeter.Load() |
| 111 | if ref == nil || !e.analyticsEnabled(rt) { |
| 112 | return |
| 113 | } |
| 114 | // One observation per served response (geoip spec §9, codex R4-1): the |
| 115 | // hub that forwarded this over the tunnel already observed it, from its |
| 116 | // own cache hit or from this response. |
| 117 | if fromTunnel(r.Context()) { |
| 118 | return |
| 119 | } |
| 120 | geoAns, _ := geoAnswerFrom(r.Context()) |
| 121 | refHost := "" |
| 122 | if u, err := url.Parse(r.Header.Get("Referer")); err == nil { |
| 123 | refHost = u.Hostname() |
| 124 | } |
| 125 | prefetch := isPrefetchPurpose(r.Header.Get("Sec-Purpose")) || isPrefetchPurpose(r.Header.Get("Purpose")) |
| 126 | ref.Observe(analytics.Obs{ |
| 127 | Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host, |
| 128 | Method: r.Method, Status: ob.status, ContentType: ob.ctype, |
| 129 | Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"), |
| 130 | ClientIP: clientIP(r), Bytes: bytes, |
| 131 | Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")), |
| 132 | When: time.Now(), |
| 133 | Country: geoAns.Country, GeoSource: geoAns.Source, GeoState: string(geoAns.State), |
| 134 | }) |
| 135 | } |
| 136 | |