Commit bdd5983

Antonio Mika  ·  2026-10-25 22:57:04 -0400 EDT
parent eea11fa
feat(metrics): label http requests and bytes with a resource
4 files changed,  +199, -68
+26, -12
......@@ -26,7 +26,7 @@ func TestSiteMetricsCountCacheHits(t *testing.T) {
2626 cfg.Domain = "pgs.test"
2727 httpCache, _ := NewPgsHttpCache(cfg, newWebRouter(cfg))
2828 reg := prometheus.NewRegistry()
29- h := router.NewUserMetrics("pgs", reg, router.UserMetricsOpts{SiteUser: siteUser(cfg)}).Handler(httpCache)
29+ h := router.NewUserMetrics("pgs", reg, router.UserMetricsOpts{Owner: siteProject(cfg)}).Handler(httpCache)
3030
3131 var statuses []string
3232 for range 2 {
......@@ -42,20 +42,34 @@ func TestSiteMetricsCountCacheHits(t *testing.T) {
4242 if err != nil {
4343 t.Fatal(err)
4444 }
45- var count float64
46- for _, family := range families {
47- if family.GetName() != "pico_http_requests_total" {
48- continue
49- }
50- for _, m := range family.GetMetric() {
51- for _, l := range m.GetLabel() {
52- if l.GetName() == "user" && l.GetValue() == user.Name {
53- count += m.GetCounter().GetValue()
45+ count := func(name string, labels map[string]string) float64 {
46+ var total float64
47+ for _, family := range families {
48+ if family.GetName() != name {
49+ continue
50+ }
51+ metrics:
52+ for _, m := range family.GetMetric() {
53+ matched := 0
54+ for _, l := range m.GetLabel() {
55+ if want, ok := labels[l.GetName()]; ok {
56+ if want != l.GetValue() {
57+ continue metrics
58+ }
59+ matched++
60+ }
61+ }
62+ if matched == len(labels) {
63+ total += m.GetCounter().GetValue()
5464 }
5565 }
5666 }
67+ return total
68+ }
69+ if got := count("pico_http_requests_total", map[string]string{"user": user.Name}); got != 2 {
70+ t.Errorf("requests for %s = %v, want 2", user.Name, got)
5771 }
58- if count != 2 {
59- t.Errorf("requests for %s = %v, want 2", user.Name, count)
72+ if got := count("pico_http_requests_total", map[string]string{"user": user.Name, "resource": "test"}); got != 2 {
73+ t.Errorf("requests for %s/test = %v, want 2", user.Name, got)
6074 }
6175 }
+10, -7
......@@ -134,23 +134,23 @@ func NewPgsHttpCache(cfg *Config, upstream http.Handler) (*httpcache.HttpCache,
134134 return httpCache, cache
135135 }
136136
137-// NewSiteMetrics counts requests for each site owner. The owner comes from
138-// the host, so responses served from a cache count too.
137+// NewSiteMetrics counts requests for each site owner and project. Both come
138+// from the host, so responses served from a cache count too.
139139 func NewSiteMetrics(cfg *Config, app string, trustSuccess bool) *router.UserMetrics {
140140 return router.NewUserMetrics(app, prometheus.DefaultRegisterer, router.UserMetricsOpts{
141- SiteUser: siteUser(cfg),
141+ Owner: siteProject(cfg),
142142 TrustSuccess: trustSuccess,
143143 })
144144 }
145145
146-func siteUser(cfg *Config) func(r *http.Request) string {
147- return func(r *http.Request) string {
146+func siteProject(cfg *Config) func(r *http.Request) (string, string) {
147+ return func(r *http.Request) (string, string) {
148148 subdomain := router.GetSubdomainFromRequest(r, cfg.Domain, cfg.TxtPrefix)
149149 if subdomain == "" {
150- return ""
150+ return "", ""
151151 }
152152 props, _ := router.GetProjectFromSubdomain(subdomain)
153- return props.Username
153+ return props.Username, props.ProjectName
154154 }
155155 }
156156
......@@ -523,6 +523,9 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
523523 return
524524 }
525525 router.SetUser(r, site.user.Name)
526+ // The name from the host, so cache hits for it count under the same
527+ // project.
528+ router.SetResource(r, props.ProjectName)
526529 user, project, bucket := site.user, site.project, site.bucket
527530
528531 logger = logger.With(
+83, -38
......@@ -6,6 +6,7 @@ import (
66 "net"
77 "net/http"
88 "strconv"
9+ "strings"
910 "time"
1011
1112 lru "github.com/hashicorp/golang-lru/v2"
......@@ -16,26 +17,28 @@ import (
1617 // UserMetricsOpts tells UserMetrics who a request belongs to when no
1718 // handler called SetUser, such as a response served from a cache.
1819 type UserMetricsOpts struct {
19- // SiteUser names the owner of the site a request is for, without
20- // checking that the user exists.
21- SiteUser func(r *http.Request) string
22- // TrustSuccess counts a SiteUser as real once its site returns a
23- // success, for a proxy that can't look users up. Only safe when every
24- // route on a site's host fails for a site that doesn't exist.
20+ // Owner names the user and resource a request is for, read from the
21+ // host without checking that either exists.
22+ Owner func(r *http.Request) (user, resource string)
23+ // TrustSuccess counts an Owner as real once it returns a success, for
24+ // a proxy that can't look users up. Only safe when every route on a
25+ // site's host fails for a site that doesn't exist.
2526 TrustSuccess bool
2627 }
2728
2829 // UserMetrics counts HTTP requests, latency and response bytes for each
29-// site owner. Every app shares the metric names and sets the app label, so
30+// site owner. Requests and bytes also carry the resource they were for,
31+// such as a pgs project, which the latency histogram leaves out to keep its
32+// series down. Every app shares the metric names and sets the app label, so
3033 // queries can aggregate across services.
3134 type UserMetrics struct {
3235 requests *prometheus.CounterVec
3336 duration *prometheus.HistogramVec
3437 bytes *prometheus.CounterVec
3538 opts UserMetricsOpts
36- // known holds the users a handler or a successful response confirmed.
37- // A name from the Host header alone never becomes a label, so made-up
38- // subdomains can't add series.
39+ // known holds the users and resources a handler or a successful
40+ // response confirmed. A name from the Host header alone never becomes a
41+ // label, so made-up subdomains can't add series.
3942 known *lru.Cache[string, struct{}]
4043 }
4144
......@@ -49,7 +52,7 @@ func NewUserMetrics(app string, reg prometheus.Registerer, opts UserMetricsOpts)
4952 ConstLabels: prometheus.Labels{"app": app},
5053 Name: "requests_total",
5154 Help: "Number of HTTP requests handled.",
52- }, []string{"user", "status", "method"}),
55+ }, []string{"user", "resource", "status", "method"}),
5356 duration: auto.NewHistogramVec(prometheus.HistogramOpts{
5457 Namespace: "pico",
5558 Subsystem: "http",
......@@ -64,62 +67,104 @@ func NewUserMetrics(app string, reg prometheus.Registerer, opts UserMetricsOpts)
6467 ConstLabels: prometheus.Labels{"app": app},
6568 Name: "response_bytes_total",
6669 Help: "Number of response body bytes sent.",
67- }, []string{"user"}),
70+ }, []string{"user", "resource"}),
6871 opts: opts,
6972 known: known,
7073 }
7174 }
7275
73-type ctxUserKey struct{}
76+type owner struct {
77+ user string
78+ resource string
79+}
80+
81+type ctxOwnerKey struct{}
7482
7583 // SetUser records name as the user a request belongs to. Handlers call it
7684 // once they've found the user in the database.
7785 func SetUser(r *http.Request, name string) {
78- if user, ok := r.Context().Value(ctxUserKey{}).(*string); ok {
79- *user = name
86+ if o, ok := r.Context().Value(ctxOwnerKey{}).(*owner); ok {
87+ o.user = name
88+ }
89+}
90+
91+// SetResource records name as what a request is for within the user's
92+// account, such as a pgs project. Handlers call it alongside SetUser once
93+// they've found it.
94+func SetResource(r *http.Request, name string) {
95+ if o, ok := r.Context().Value(ctxOwnerKey{}).(*owner); ok {
96+ o.resource = name
8097 }
8198 }
8299
83100 func (m *UserMetrics) Handler(next http.Handler) http.Handler {
84101 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85102 start := time.Now()
86- var user string
103+ var o owner
87104 mw := &metricsWriter{ResponseWriter: w}
88- next.ServeHTTP(mw, r.WithContext(context.WithValue(r.Context(), ctxUserKey{}, &user)))
105+ next.ServeHTTP(mw, r.WithContext(context.WithValue(r.Context(), ctxOwnerKey{}, &o)))
89106
90107 status := mw.Status()
91- m.Observe(m.resolveUser(r, user, status), status, r.Method, time.Since(start), mw.bytes)
108+ user, resource := m.resolve(r, o, status)
109+ m.Observe(user, resource, status, r.Method, time.Since(start), mw.bytes)
92110 })
93111 }
94112
95113 // Observe records one request for servers that can't use Handler, such as
96-// tuns's gin logger. user must already be confirmed to exist. A negative
97-// size, which gin reports when nothing was written, counts as zero bytes.
98-func (m *UserMetrics) Observe(user string, status int, method string, elapsed time.Duration, bytes int64) {
99- labels := []string{user, strconv.Itoa(status), metricMethod(method)}
100- m.requests.WithLabelValues(labels...).Inc()
101- m.duration.WithLabelValues(labels...).Observe(elapsed.Seconds())
102- m.bytes.WithLabelValues(user).Add(float64(max(bytes, 0)))
114+// tuns's gin logger. user and resource must already be confirmed to
115+// exist, and resource may be empty. A negative size, which gin reports when
116+// nothing was written, counts as zero bytes.
117+func (m *UserMetrics) Observe(user, resource string, status int, method string, elapsed time.Duration, bytes int64) {
118+ if user == "" {
119+ resource = ""
120+ }
121+ code, verb := strconv.Itoa(status), metricMethod(method)
122+ m.requests.WithLabelValues(user, resource, code, verb).Inc()
123+ m.duration.WithLabelValues(user, code, verb).Observe(elapsed.Seconds())
124+ m.bytes.WithLabelValues(user, resource).Add(float64(max(bytes, 0)))
103125 }
104126
105-func (m *UserMetrics) resolveUser(r *http.Request, user string, status int) string {
106- if user != "" {
107- m.known.Add(user, struct{}{})
108- return user
127+func (m *UserMetrics) resolve(r *http.Request, o owner, status int) (string, string) {
128+ var hostUser, hostResource string
129+ if m.opts.Owner != nil {
130+ hostUser, hostResource = m.opts.Owner(r)
109131 }
110- if m.opts.SiteUser == nil {
111- return ""
112- }
113- user = m.opts.SiteUser(r)
132+ trusted := m.opts.TrustSuccess && (status >= 200 && status < 300 || status == http.StatusNotModified)
133+
134+ user := m.confirm(o.user, hostUser, trusted)
114135 if user == "" {
136+ return "", ""
137+ }
138+ // Resources are keyed under their owner, and user names can't hold a
139+ // slash.
140+ prefix := user + "/"
141+ set, fromHost := "", ""
142+ if o.resource != "" {
143+ set = prefix + o.resource
144+ }
145+ if hostUser == user && hostResource != "" {
146+ fromHost = prefix + hostResource
147+ }
148+ resource := strings.TrimPrefix(m.confirm(set, fromHost, trusted), prefix)
149+ return user, resource
150+}
151+
152+// confirm returns the name a handler set, or else the name from the host
153+// once it's known or trusted.
154+func (m *UserMetrics) confirm(set, fromHost string, trusted bool) string {
155+ if set != "" {
156+ m.known.Add(set, struct{}{})
157+ return set
158+ }
159+ if fromHost == "" {
115160 return ""
116161 }
117- if m.known.Contains(user) {
118- return user
162+ if m.known.Contains(fromHost) {
163+ return fromHost
119164 }
120- if m.opts.TrustSuccess && (status >= 200 && status < 300 || status == http.StatusNotModified) {
121- m.known.Add(user, struct{}{})
122- return user
165+ if trusted {
166+ m.known.Add(fromHost, struct{}{})
167+ return fromHost
123168 }
124169 return ""
125170 }
+80, -11
......@@ -9,13 +9,15 @@ import (
99 "github.com/prometheus/client_golang/prometheus"
1010 )
1111
12-// metricValue reads a counter, or a histogram's count, from reg.
12+// metricValue sums the counters, or histogram counts, in reg that match
13+// labels.
1314 func metricValue(t *testing.T, reg *prometheus.Registry, name string, labels map[string]string) float64 {
1415 t.Helper()
1516 families, err := reg.Gather()
1617 if err != nil {
1718 t.Fatalf("gather: %v", err)
1819 }
20+ var total float64
1921 for _, family := range families {
2022 if family.GetName() != name {
2123 continue
......@@ -28,12 +30,20 @@ func metricValue(t *testing.T, reg *prometheus.Registry, name string, labels map
2830 }
2931 }
3032 if h := m.GetHistogram(); h != nil {
31- return float64(h.GetSampleCount())
33+ total += float64(h.GetSampleCount())
34+ } else {
35+ total += m.GetCounter().GetValue()
3236 }
33- return m.GetCounter().GetValue()
3437 }
3538 }
36- return 0
39+ return total
40+}
41+
42+// hostOwner reads "user-project.example.com" the way pgs reads its
43+// subdomains.
44+func hostOwner(r *http.Request) (string, string) {
45+ user, resource, _ := strings.Cut(strings.Split(r.Host, ".")[0], "-")
46+ return user, resource
3747 }
3848
3949 func serve(h http.Handler, method, target string) *httptest.ResponseRecorder {
......@@ -67,9 +77,7 @@ func TestUserMetricsSetUser(t *testing.T) {
6777
6878 func TestUserMetricsSiteUser(t *testing.T) {
6979 reg := prometheus.NewRegistry()
70- m := NewUserMetrics("test", reg, UserMetricsOpts{
71- SiteUser: func(r *http.Request) string { return strings.Split(r.Host, ".")[0] },
72- })
80+ m := NewUserMetrics("test", reg, UserMetricsOpts{Owner: hostOwner})
7381 // The handler confirms alice on her first request; later ones stand in
7482 // for cache hits that never reach it.
7583 confirmed := false
......@@ -98,7 +106,7 @@ func TestUserMetricsSiteUser(t *testing.T) {
98106 func TestUserMetricsTrustSuccess(t *testing.T) {
99107 reg := prometheus.NewRegistry()
100108 m := NewUserMetrics("test", reg, UserMetricsOpts{
101- SiteUser: func(r *http.Request) string { return strings.Split(r.Host, ".")[0] },
109+ Owner: hostOwner,
102110 TrustSuccess: true,
103111 })
104112 h := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
......@@ -107,7 +115,7 @@ func TestUserMetricsTrustSuccess(t *testing.T) {
107115 }
108116 }))
109117
110- serve(h, http.MethodGet, "http://bob.example.com/")
118+ serve(h, http.MethodGet, "http://bob-docs.example.com/")
111119 serve(h, http.MethodGet, "http://ghost.example.com/")
112120
113121 if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "bob", "status": "200"}); got != 1 {
......@@ -116,6 +124,64 @@ func TestUserMetricsTrustSuccess(t *testing.T) {
116124 if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "", "status": "404"}); got != 1 {
117125 t.Errorf("unlabeled 404s = %v, want 1", got)
118126 }
127+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "bob", "resource": "docs"}); got != 1 {
128+ t.Errorf("bob/docs requests = %v, want 1", got)
129+ }
130+}
131+
132+func TestUserMetricsSetResource(t *testing.T) {
133+ reg := prometheus.NewRegistry()
134+ m := NewUserMetrics("test", reg, UserMetricsOpts{})
135+ h := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+ SetUser(r, "alice")
137+ if r.URL.Path == "/blog" {
138+ SetResource(r, "blog")
139+ }
140+ _, _ = w.Write([]byte("hello"))
141+ }))
142+
143+ serve(h, http.MethodGet, "/blog")
144+ serve(h, http.MethodGet, "/")
145+
146+ labels := map[string]string{"app": "test", "user": "alice", "resource": "blog"}
147+ if got := metricValue(t, reg, "pico_http_requests_total", labels); got != 1 {
148+ t.Errorf("alice/blog requests = %v, want 1", got)
149+ }
150+ if got := metricValue(t, reg, "pico_http_response_bytes_total", labels); got != 5 {
151+ t.Errorf("alice/blog bytes = %v, want 5", got)
152+ }
153+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "alice"}); got != 2 {
154+ t.Errorf("alice requests = %v, want 2", got)
155+ }
156+}
157+
158+func TestUserMetricsResourceFromHost(t *testing.T) {
159+ reg := prometheus.NewRegistry()
160+ m := NewUserMetrics("test", reg, UserMetricsOpts{Owner: hostOwner})
161+ // The handler confirms alice's blog on its first request; later ones
162+ // stand in for cache hits that never reach it.
163+ confirmed := false
164+ h := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ if !confirmed {
166+ SetUser(r, "alice")
167+ SetResource(r, "blog")
168+ confirmed = true
169+ }
170+ }))
171+
172+ serve(h, http.MethodGet, "http://alice-blog.example.com/")
173+ serve(h, http.MethodGet, "http://alice-blog.example.com/")
174+ serve(h, http.MethodGet, "http://alice-ghost.example.com/")
175+
176+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "alice", "resource": "blog"}); got != 2 {
177+ t.Errorf("alice/blog requests = %v, want 2", got)
178+ }
179+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "alice", "resource": ""}); got != 1 {
180+ t.Errorf("alice requests without a resource = %v, want 1", got)
181+ }
182+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "alice"}); got != 3 {
183+ t.Errorf("alice requests = %v, want 3", got)
184+ }
119185 }
120186
121187 func TestUserMetricsMethod(t *testing.T) {
......@@ -133,8 +199,8 @@ func TestUserMetricsObserve(t *testing.T) {
133199 reg := prometheus.NewRegistry()
134200 m := NewUserMetrics("test", reg, UserMetricsOpts{})
135201
136- m.Observe("carol", http.StatusOK, http.MethodGet, 0, 7)
137- m.Observe("carol", http.StatusNotModified, "MADEUP", 0, -1)
202+ m.Observe("carol", "app", http.StatusOK, http.MethodGet, 0, 7)
203+ m.Observe("carol", "", http.StatusNotModified, "MADEUP", 0, -1)
138204
139205 if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "carol", "status": "200", "method": "GET"}); got != 1 {
140206 t.Errorf("200 requests = %v, want 1", got)
......@@ -145,6 +211,9 @@ func TestUserMetricsObserve(t *testing.T) {
145211 if got := metricValue(t, reg, "pico_http_response_bytes_total", map[string]string{"user": "carol"}); got != 7 {
146212 t.Errorf("bytes = %v, want 7", got)
147213 }
214+ if got := metricValue(t, reg, "pico_http_requests_total", map[string]string{"user": "carol", "resource": "app"}); got != 1 {
215+ t.Errorf("carol/app requests = %v, want 1", got)
216+ }
148217 }
149218
150219 func TestUserMetricsHijack(t *testing.T) {