Просмотр исходного кода

ipn/localapi: add API for uploading client metrics

Clients may have platform-specific metrics they would like uploaded
(e.g. extracted from MetricKit on iOS). Add a new local API endpoint
that allows metrics to be updated by a simple name/value JSON-encoded
struct.

Signed-off-by: Mihai Parparita <[email protected]>
Mihai Parparita 3 лет назад
Родитель
Сommit
e37167b3ef
2 измененных файлов с 70 добавлено и 0 удалено
  1. 61 0
      ipn/localapi/localapi.go
  2. 9 0
      util/clientmetric/clientmetric.go

+ 61 - 0
ipn/localapi/localapi.go

@@ -21,6 +21,7 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"inet.af/netaddr"
@@ -41,6 +42,16 @@ func randHex(n int) string {
 	return hex.EncodeToString(b)
 }
 
+var (
+	// The clientmetrics package is stateful, but we want to expose a simple
+	// imperative API to local clients, so we need to keep track of
+	// clientmetric.Metric instances that we've created for them. These need to
+	// be globals because we end up creating many Handler instances for the
+	// lifetime of a client.
+	metricsMu sync.Mutex
+	metrics   = map[string]*clientmetric.Metric{}
+)
+
 func NewHandler(b *ipnlocal.LocalBackend, logf logger.Logf, logID string) *Handler {
 	return &Handler{b: b, logf: logf, backendLogID: logID}
 }
@@ -137,6 +148,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		h.serveDial(w, r)
 	case "/localapi/v0/id-token":
 		h.serveIDToken(w, r)
+	case "/localapi/v0/upload-client-metrics":
+		h.serveUploadClientMetrics(w, r)
 	case "/":
 		io.WriteString(w, "tailscaled\n")
 	default:
@@ -730,6 +743,54 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
 	<-errc
 }
 
+func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
+		return
+	}
+	type clientMetricJSON struct {
+		Name string `json:"name"`
+		// One of "counter" or "gauge"
+		Type  string `json:"type"`
+		Value int    `json:"value"`
+	}
+
+	var clientMetrics []clientMetricJSON
+	if err := json.NewDecoder(r.Body).Decode(&clientMetrics); err != nil {
+		http.Error(w, "invalid JSON body", 400)
+		return
+	}
+
+	metricsMu.Lock()
+	defer metricsMu.Unlock()
+
+	for _, m := range clientMetrics {
+		if metric, ok := metrics[m.Name]; ok {
+			metric.Add(int64(m.Value))
+		} else {
+			if clientmetric.HasPublished(m.Name) {
+				http.Error(w, "Already have a metric named "+m.Name, 400)
+				return
+			}
+			var metric *clientmetric.Metric
+			switch m.Type {
+			case "counter":
+				metric = clientmetric.NewCounter(m.Name)
+			case "gauge":
+				metric = clientmetric.NewGauge(m.Name)
+			default:
+				http.Error(w, "Unknown metric type "+m.Type, 400)
+				return
+			}
+			metrics[m.Name] = metric
+			metric.Add(int64(m.Value))
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(struct{}{})
+}
+
 func defBool(a string, def bool) bool {
 	if a == "" {
 		return def

+ 9 - 0
util/clientmetric/clientmetric.go

@@ -136,6 +136,15 @@ func Metrics() []*Metric {
 	return sorted
 }
 
+// HasPublished reports whether a metric with the given name has already been
+// published.
+func HasPublished(name string) bool {
+	mu.Lock()
+	defer mu.Unlock()
+	_, ok := metrics[name]
+	return ok
+}
+
 // NewUnpublished initializes a new Metric without calling Publish on
 // it.
 func NewUnpublished(name string, typ Type) *Metric {