Skip to content

Commit

Permalink
feat: Add metrics instrumentation using OpenCensus. (#238)
Browse files Browse the repository at this point in the history
* Add OpenCensus metrics instrumentation.

By default it will use a no-op logger, an opencensus logger is also provided.

* Add documentation for monitoring changes.

* Provide a link to explain how to export data.
  • Loading branch information
yangd102 authored May 7, 2020
1 parent dea6358 commit e6c76e5
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 3 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ instead of an API key.

Native objects for each of the API responses.

### Monitoring

It's possible to get metrics for status counts and latency histograms for monitoring.
Use `maps.WithMetricReporter(metrics.OpenCensusReporter{})` to log metrics to OpenCensus,
and `metrics.RegisterViews()` to make the metrics available to be exported.
OpenCensus can export these metrics to a [variety of monitoring services](https://opencensus.io/exporters/).
You can also implement your own metric reporter instead of using the provided one.

[apikey]: https://developers.google.com/maps/faq#keysystem
[clientid]: https://developers.google.com/maps/documentation/business/webservices/auth

Expand Down
28 changes: 25 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"golang.org/x/time/rate"
"googlemaps.github.io/maps/internal"
"googlemaps.github.io/maps/metrics"
)

// Client may be used to make requests to the Google Maps WebService APIs
Expand All @@ -41,6 +42,7 @@ type Client struct {
rateLimiter *rate.Limiter
channel string
experienceId []string
metricReporter metrics.Reporter
}

// ClientOption is the type of constructor options for NewClient(...).
Expand All @@ -55,7 +57,10 @@ const (
// NewClient constructs a new Client which can make requests to the Google Maps
// WebService APIs.
func NewClient(options ...ClientOption) (*Client, error) {
c := &Client{requestsPerSecond: defaultRequestsPerSecond}
c := &Client{
requestsPerSecond: defaultRequestsPerSecond,
metricReporter: metrics.NoOpReporter{},
}
WithHTTPClient(&http.Client{})(c)
for _, option := range options {
err := option(c)
Expand Down Expand Up @@ -161,6 +166,13 @@ func WithExperienceId(ids ...string) ClientOption {
}
}

func WithMetricReporter(reporter metrics.Reporter) ClientOption {
return func(c *Client) error {
c.metricReporter = reporter
return nil
}
}

type apiConfig struct {
host string
path string
Expand Down Expand Up @@ -243,23 +255,31 @@ func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, err
}

func (c *Client) getJSON(ctx context.Context, config *apiConfig, apiReq apiRequest, resp interface{}) error {
requestMetrics := c.metricReporter.NewRequest(config.path)
httpResp, err := c.get(ctx, config, apiReq)
if err != nil {
requestMetrics.EndRequest(ctx, err, httpResp, "")
return err
}
defer httpResp.Body.Close()

return json.NewDecoder(httpResp.Body).Decode(resp)
err = json.NewDecoder(httpResp.Body).Decode(resp)
requestMetrics.EndRequest(ctx, err, httpResp, httpResp.Header.Get("x-goog-maps-metro-area"))
return err
}

func (c *Client) postJSON(ctx context.Context, config *apiConfig, apiReq interface{}, resp interface{}) error {
requestMetrics := c.metricReporter.NewRequest(config.path)
httpResp, err := c.post(ctx, config, apiReq)
if err != nil {
requestMetrics.EndRequest(ctx, err, httpResp, "")
return err
}
defer httpResp.Body.Close()

return json.NewDecoder(httpResp.Body).Decode(resp)
err = json.NewDecoder(httpResp.Body).Decode(resp)
requestMetrics.EndRequest(ctx, err, httpResp, httpResp.Header.Get("x-goog-maps-metro-area"))
return err
}

func (c *Client) setExperienceId(ids ...string) {
Expand Down Expand Up @@ -287,7 +307,9 @@ type binaryResponse struct {
}

func (c *Client) getBinary(ctx context.Context, config *apiConfig, apiReq apiRequest) (binaryResponse, error) {
requestMetrics := c.metricReporter.NewRequest(config.path)
httpResp, err := c.get(ctx, config, apiReq)
requestMetrics.EndRequest(ctx, err, httpResp, httpResp.Header.Get("x-goog-maps-metro-area"))
if err != nil {
return binaryResponse{}, err
}
Expand Down
27 changes: 27 additions & 0 deletions metrics/metric.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package metrics

import (
"context"
"net/http"
)

type Reporter interface {
NewRequest(name string) Request
}

type Request interface {
EndRequest(ctx context.Context, err error, httpResp *http.Response, metro string)
}

type NoOpReporter struct {
}

func (n NoOpReporter) NewRequest(name string) Request {
return noOpRequest{}
}

type noOpRequest struct {
}

func (n noOpRequest) EndRequest(ctx context.Context, err error, httpResp *http.Response, metro string) {
}
71 changes: 71 additions & 0 deletions metrics/metric_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package metrics_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"googlemaps.github.io/maps"
"googlemaps.github.io/maps/metrics"
)

type testReporter struct {
start, end int
}

func (t *testReporter) NewRequest(name string) metrics.Request {
t.start++
return &testMetric{reporter: t}
}

type testMetric struct {
reporter *testReporter
}

func (t *testMetric) EndRequest(ctx context.Context, err error, httpResp *http.Response, metro string) {
t.reporter.end++
}

func mockServer(codes []int, body string) *httptest.Server {
i := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(codes[i])
i++
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
fmt.Fprintln(w, body)
}))
return server
}

func TestClientWithMetricReporter(t *testing.T) {
server := mockServer([]int{200}, `{"results" : [], "status" : "OK"}`)
defer server.Close()
reporter := &testReporter{}
c, err := maps.NewClient(
maps.WithAPIKey("AIza-Maps-API-Key"),
maps.WithBaseURL(server.URL),
maps.WithMetricReporter(reporter))
if err != nil {
t.Errorf("Unable to create client with MetricReporter")
}
r := &maps.ElevationRequest{
Locations: []maps.LatLng{
{
Lat: 39.73915360,
Lng: -104.9847034,
},
},
}
_, err = c.Elevation(context.Background(), r)
if err != nil {
t.Errorf("r.Get returned non nil error, was %+v", err)
}
if reporter.start != 1 {
t.Errorf("expected one start call")
}
if reporter.end != 1 {
t.Errorf("expected one end call")
}
}
74 changes: 74 additions & 0 deletions metrics/opencensus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package metrics

import (
"context"
"go.opencensus.io/stats"
"go.opencensus.io/stats/view"
"go.opencensus.io/tag"
"net/http"
"strconv"
"time"
)

var (
latency_measure = stats.Int64("maps.googleapis.com/measure/client/latency", "Latency in msecs", stats.UnitMilliseconds)

requestName = tag.MustNewKey("request_name")
apiStatus = tag.MustNewKey("api_status")
httpCode = tag.MustNewKey("http_code")
metroArea = tag.MustNewKey("metro_area")

Count = &view.View{
Name: "maps.googleapis.com/client/count",
Description: "Request Counts",
TagKeys: []tag.Key{requestName, apiStatus, httpCode, metroArea},
Measure: latency_measure,
Aggregation: view.Count(),
}

Latency = &view.View{
Name: "maps.googleapis.com/client/request_latency",
Description: "Total time between library method called and results returned",
TagKeys: []tag.Key{requestName, apiStatus, httpCode, metroArea},
Measure: latency_measure,
Aggregation: view.Distribution(20.0, 25.2, 31.7, 40.0, 50.4, 63.5, 80.0, 100.8, 127.0, 160.0, 201.6, 254.0, 320.0, 403.2, 508.0, 640.0, 806.3, 1015.9, 1280.0, 1612.7, 2031.9, 2560.0, 3225.4, 4063.7),
}
)

func RegisterViews() error {
return view.Register(Latency, Count)
}

type OpenCensusReporter struct {
}

func (o OpenCensusReporter) NewRequest(name string) Request {
return &openCensusRequest{
name: name,
start: time.Now().UnixNano() / int64(time.Millisecond),
}
}

type openCensusRequest struct {
name string
start int64
}

func (o *openCensusRequest) EndRequest(ctx context.Context, err error, httpResp *http.Response, metro string) {
now := time.Now().UnixNano() / int64(time.Millisecond)
duration := now - o.start
errStr := ""
if err != nil {
errStr = err.Error()
}
httpCodeStr := ""
if httpResp != nil {
httpCodeStr = strconv.Itoa(httpResp.StatusCode)
}
stats.RecordWithTags(ctx, []tag.Mutator{
tag.Upsert(requestName, o.name),
tag.Upsert(apiStatus, errStr),
tag.Upsert(httpCode, httpCodeStr),
tag.Upsert(metroArea, metro),
}, latency_measure.M(duration))
}
43 changes: 43 additions & 0 deletions metrics/opencensus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package metrics_test

import (
"context"
"go.opencensus.io/stats/view"
"testing"

"googlemaps.github.io/maps"
"googlemaps.github.io/maps/metrics"
)

func TestClientWithOpenCensus(t *testing.T) {
metrics.RegisterViews()
server := mockServer([]int{200, 400}, `{"results" : [], "status" : "OK"}`)
defer server.Close()
c, err := maps.NewClient(
maps.WithAPIKey("AIza-Maps-API-Key"),
maps.WithBaseURL(server.URL),
maps.WithMetricReporter(metrics.OpenCensusReporter{}))
if err != nil {
t.Errorf("Unable to create client with OpenCensusReporter")
}
r := &maps.ElevationRequest{
Locations: []maps.LatLng{
{
Lat: 39.73915360,
Lng: -104.9847034,
},
},
}
_, err = c.Elevation(context.Background(), r)
if err != nil {
t.Errorf("r.Get returned non nil error, was %+v", err)
}
_, err = c.Elevation(context.Background(), r)
if err != nil {
t.Errorf("r.Get returned non nil error, was %+v", err)
}
count, _ := view.RetrieveData("maps.googleapis.com/client/count")
if len(count) != 2 {
t.Errorf("expected two metrics, got %v", len(count))
}
}

0 comments on commit e6c76e5

Please sign in to comment.