internal/metrics: add new package for recording metrics

Move service code from sandbox/metrics.go to
internal/metrics/service.go. This will enable sharing of the code for
the playground front-end, allowing us to record metrics there as well.

For golang/go#44822

Change-Id: I592486cdffd62dd6b9cee6cadb56ddc027788f59
Reviewed-on: https://go-review.googlesource.com/c/playground/+/302769
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/internal/metrics/service.go b/internal/metrics/service.go
new file mode 100644
index 0000000..6004e8d
--- /dev/null
+++ b/internal/metrics/service.go
@@ -0,0 +1,161 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package metrics provides a service for reporting metrics to
+// Stackdriver, or locally during development.
+package metrics
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"path"
+	"time"
+
+	"cloud.google.com/go/compute/metadata"
+	"contrib.go.opencensus.io/exporter/prometheus"
+	"contrib.go.opencensus.io/exporter/stackdriver"
+	"go.opencensus.io/stats/view"
+	mrpb "google.golang.org/genproto/googleapis/api/monitoredres"
+)
+
+// NewService initializes a *Service.
+//
+// The Service returned is configured to send metric data to
+// StackDriver. When not running on GCE, it will host metrics through
+// a prometheus HTTP handler.
+//
+// views will be passed to view.Register for export to the metric
+// service.
+func NewService(resource *MonitoredResource, views []*view.View) (*Service, error) {
+	err := view.Register(views...)
+	if err != nil {
+		return nil, err
+	}
+
+	if !metadata.OnGCE() {
+		view.SetReportingPeriod(5 * time.Second)
+		pe, err := prometheus.NewExporter(prometheus.Options{})
+		if err != nil {
+			return nil, fmt.Errorf("prometheus.NewExporter: %w", err)
+		}
+		view.RegisterExporter(pe)
+		return &Service{pExporter: pe}, nil
+	}
+
+	projID, err := metadata.ProjectID()
+	if err != nil {
+		return nil, err
+	}
+	if resource == nil {
+		return nil, errors.New("resource is required, got nil")
+	}
+	sde, err := stackdriver.NewExporter(stackdriver.Options{
+		ProjectID:         projID,
+		MonitoredResource: resource,
+		ReportingInterval: time.Minute, // Minimum interval for Stackdriver is 1 minute.
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	// Minimum interval for Stackdriver is 1 minute.
+	view.SetReportingPeriod(time.Minute)
+	// Start the metrics exporter.
+	if err := sde.StartMetricsExporter(); err != nil {
+		return nil, err
+	}
+
+	return &Service{sdExporter: sde}, nil
+}
+
+// Service controls metric exporters.
+type Service struct {
+	sdExporter *stackdriver.Exporter
+	pExporter  *prometheus.Exporter
+}
+
+func (m *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if m.pExporter != nil {
+		m.pExporter.ServeHTTP(w, r)
+		return
+	}
+	http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+}
+
+// Stop flushes metrics and stops exporting. Stop should be called
+// before exiting.
+func (m *Service) Stop() {
+	if sde := m.sdExporter; sde != nil {
+		// Flush any unsent data before exiting.
+		sde.Flush()
+
+		sde.StopMetricsExporter()
+	}
+}
+
+// MonitoredResource wraps a *mrpb.MonitoredResource to implement the
+// monitoredresource.MonitoredResource interface.
+type MonitoredResource mrpb.MonitoredResource
+
+func (r *MonitoredResource) MonitoredResource() (resType string, labels map[string]string) {
+	return r.Type, r.Labels
+}
+
+// GCEResource populates a MonitoredResource with GCE Metadata.
+//
+// The returned MonitoredResource will have the type set to "generic_task".
+func GCEResource(jobName string) (*MonitoredResource, error) {
+	projID, err := metadata.ProjectID()
+	if err != nil {
+		return nil, err
+	}
+	zone, err := metadata.Zone()
+	if err != nil {
+		return nil, err
+	}
+	inst, err := metadata.InstanceName()
+	if err != nil {
+		return nil, err
+	}
+	group, err := instanceGroupName()
+	if err != nil {
+		return nil, err
+	} else if group == "" {
+		group = projID
+	}
+
+	return (*MonitoredResource)(&mrpb.MonitoredResource{
+		Type: "generic_task", // See: https://cloud.google.com/monitoring/api/resources#tag_generic_task
+		Labels: map[string]string{
+			"project_id": projID,
+			"location":   zone,
+			"namespace":  group,
+			"job":        jobName,
+			"task_id":    inst,
+		},
+	}), nil
+}
+
+// instanceGroupName fetches the instanceGroupName from the instance
+// metadata.
+//
+// The instance group manager applies a custom "created-by" attribute
+// to the instance, which is not part of the metadata package API, and
+// must be queried separately.
+//
+// An empty string will be returned if a metadata.NotDefinedError is
+// returned when fetching metadata. An error will be returned if other
+// errors occur when fetching metadata.
+func instanceGroupName() (string, error) {
+	ig, err := metadata.InstanceAttributeValue("created-by")
+	if errors.As(err, new(metadata.NotDefinedError)) {
+		return "", nil
+	} else if err != nil {
+		return "", err
+	}
+	// "created-by" format: "projects/{{InstanceID}}/zones/{{Zone}}/instanceGroupManagers/{{Instance Group Name}}
+	ig = path.Base(ig)
+	return ig, nil
+}
diff --git a/sandbox/metrics.go b/sandbox/metrics.go
index 075be96..7fb213e 100644
--- a/sandbox/metrics.go
+++ b/sandbox/metrics.go
@@ -5,20 +5,10 @@
 package main
 
 import (
-	"errors"
-	"fmt"
-	"net/http"
-	"path"
-	"time"
-
-	"cloud.google.com/go/compute/metadata"
-	"contrib.go.opencensus.io/exporter/prometheus"
-	"contrib.go.opencensus.io/exporter/stackdriver"
 	"go.opencensus.io/plugin/ochttp"
 	"go.opencensus.io/stats"
 	"go.opencensus.io/stats/view"
 	"go.opencensus.io/tag"
-	mrpb "google.golang.org/genproto/googleapis/api/monitoredres"
 )
 
 var (
@@ -112,148 +102,18 @@
 	}
 )
 
-// newMetricService initializes a *metricService.
-//
-// The metricService returned is configured to send metric data to StackDriver.
-// When the sandbox is not running on GCE, it will host metrics through a prometheus HTTP handler.
-func newMetricService() (*metricService, error) {
-	err := view.Register(
-		containerCount,
-		unwantedContainerCount,
-		maxContainerCount,
-		containerCreateCount,
-		containerCreationLatency,
-		ServerRequestCountView,
-		ServerRequestBytesView,
-		ServerResponseBytesView,
-		ServerLatencyView,
-		ServerRequestCountByMethod,
-		ServerResponseCountByStatusCode)
-	if err != nil {
-		return nil, err
-	}
-
-	if !metadata.OnGCE() {
-		view.SetReportingPeriod(5 * time.Second)
-		pe, err := prometheus.NewExporter(prometheus.Options{})
-		if err != nil {
-			return nil, fmt.Errorf("newMetricsService(): prometheus.NewExporter: %w", err)
-		}
-		view.RegisterExporter(pe)
-		return &metricService{pExporter: pe}, nil
-	}
-
-	projID, err := metadata.ProjectID()
-	if err != nil {
-		return nil, err
-	}
-	gr, err := gceResource("go-playground-sandbox")
-	if err != nil {
-		return nil, err
-	}
-
-	sd, err := stackdriver.NewExporter(stackdriver.Options{
-		ProjectID:         projID,
-		MonitoredResource: gr,
-		ReportingInterval: time.Minute, // Minimum interval for stackdriver is 1 minute.
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	// Minimum interval for stackdriver is 1 minute.
-	view.SetReportingPeriod(time.Minute)
-	// Start the metrics exporter.
-	if err := sd.StartMetricsExporter(); err != nil {
-		return nil, err
-	}
-
-	return &metricService{sdExporter: sd}, nil
-}
-
-// metricService controls metric exporters.
-type metricService struct {
-	sdExporter *stackdriver.Exporter
-	pExporter  *prometheus.Exporter
-}
-
-func (m *metricService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if m.pExporter != nil {
-		m.pExporter.ServeHTTP(w, r)
-		return
-	}
-	http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
-}
-
-// Stop flushes metrics and stops exporting. Stop should be called before exiting.
-func (m *metricService) Stop() {
-	if sde := m.sdExporter; sde != nil {
-		// Flush any unsent data before exiting.
-		sde.Flush()
-
-		sde.StopMetricsExporter()
-	}
-}
-
-// monitoredResource wraps a *mrpb.MonitoredResource to implement the
-// monitoredresource.MonitoredResource interface.
-type monitoredResource mrpb.MonitoredResource
-
-func (r *monitoredResource) MonitoredResource() (resType string, labels map[string]string) {
-	return r.Type, r.Labels
-}
-
-// gceResource populates a monitoredResource with GCE Metadata.
-//
-// The returned monitoredResource will have the type set to "generic_task".
-func gceResource(jobName string) (*monitoredResource, error) {
-	projID, err := metadata.ProjectID()
-	if err != nil {
-		return nil, err
-	}
-	zone, err := metadata.Zone()
-	if err != nil {
-		return nil, err
-	}
-	iname, err := metadata.InstanceName()
-	if err != nil {
-		return nil, err
-	}
-	igName, err := instanceGroupName()
-	if err != nil {
-		return nil, err
-	} else if igName == "" {
-		igName = projID
-	}
-
-	return (*monitoredResource)(&mrpb.MonitoredResource{
-		Type: "generic_task", // See: https://cloud.google.com/monitoring/api/resources#tag_generic_task
-		Labels: map[string]string{
-			"project_id": projID,
-			"location":   zone,
-			"namespace":  igName,
-			"job":        jobName,
-			"task_id":    iname,
-		},
-	}), nil
-}
-
-// instanceGroupName fetches the instanceGroupName from the instance metadata.
-//
-// The instance group manager applies a custom "created-by" attribute to the instance, which is not part of the
-// metadata package API, and must be queried separately.
-//
-// An empty string will be returned if a metadata.NotDefinedError is returned when fetching metadata.
-// An error will be returned if other errors occur when fetching metadata.
-func instanceGroupName() (string, error) {
-	ig, err := metadata.InstanceAttributeValue("created-by")
-	if nde := metadata.NotDefinedError(""); err != nil && !errors.As(err, &nde) {
-		return "", err
-	}
-	if ig == "" {
-		return "", nil
-	}
-	// "created-by" format: "projects/{{InstanceID}}/zones/{{Zone}}/instanceGroupManagers/{{Instance Group Name}}
-	ig = path.Base(ig)
-	return ig, err
+// views should contain all measurements. All *view.View added to this
+// slice will be registered and exported to the metric service.
+var views = []*view.View{
+	containerCount,
+	unwantedContainerCount,
+	maxContainerCount,
+	containerCreateCount,
+	containerCreationLatency,
+	ServerRequestCountView,
+	ServerRequestBytesView,
+	ServerResponseBytesView,
+	ServerLatencyView,
+	ServerRequestCountByMethod,
+	ServerResponseCountByStatusCode,
 }
diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go
index ffeb829..f65e642 100644
--- a/sandbox/sandbox.go
+++ b/sandbox/sandbox.go
@@ -31,11 +31,13 @@
 	"syscall"
 	"time"
 
+	"cloud.google.com/go/compute/metadata"
 	"go.opencensus.io/plugin/ochttp"
 	"go.opencensus.io/stats"
 	"go.opencensus.io/tag"
 	"go.opencensus.io/trace"
 	"golang.org/x/playground/internal"
+	"golang.org/x/playground/internal/metrics"
 	"golang.org/x/playground/sandbox/sandboxtypes"
 )
 
@@ -123,8 +125,12 @@
 
 	mux := http.NewServeMux()
 
-	if ms, err := newMetricService(); err != nil {
-		log.Printf("Failed to initialize metrics: newMetricService() = _, %v, wanted no error", err)
+	gr, err := metrics.GCEResource("go-playground-sandbox")
+	if err != nil && metadata.OnGCE() {
+		log.Printf("metrics.GceService(%q) = _, %v, wanted no error.", "go-playground-sandbox", err)
+	}
+	if ms, err := metrics.NewService(gr, views); err != nil {
+		log.Printf("Failed to initialize metrics: metrics.NewService() = _, %v, wanted no error", err)
 	} else {
 		mux.Handle("/statusz", ochttp.WithRouteTag(ms, "/statusz"))
 		defer ms.Stop()