Skip to content

Commit

Permalink
Allow passing multiple metrics to combine in one graph (#10)
Browse files Browse the repository at this point in the history
* allow passing multiple metrics to combine in one graph, fix period query param, fix start/end for space cost
  • Loading branch information
fishnix committed Jan 17, 2020
1 parent c587af3 commit 2ef3a31
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 47 deletions.
66 changes: 49 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# cost-api

This API provides simple restful API access to Amazon's Cost explorer service.
This API provides simple restful API access to Amazon's Cost explorer and cloudwatch metrics service.

## Endpoints

Expand All @@ -11,8 +11,8 @@ GET /v1/cost/metrics
GET /v1/cost/{account}/spaces/{spaceid}[?start=2019-10-01&end=2019-10-30]
GET /v1/cost/{account}/instances/{id}/metrics/{metric}.png[?start=-P1D&end=PT0H&period=300]
GET /v1/cost/{account}/instances/{id}/metrics/{metric}[?start=-P1D&end=PT0H&period=300]
GET /v1/cost/{account}/instances/{id}/metrics/graph.png?metric={metric1}[&metric={metric2}&start=-P1D&end=PT0H&period=300]
GET /v1/cost/{account}/instances/{id}/metrics/graph?metric={metric1}[&metric={metric2}&start=-P1D&end=PT0H&period=300]
```

## Usage
Expand All @@ -23,52 +23,84 @@ By default, this will get the month to date costs for a space id (based on the `

#### Request

```
GET /v1/cost/{account}/spaces/{spaceid}
```

#### Response

```json
{
"TBD"
}
[
{
"Estimated": true,
"Groups": [],
"TimePeriod": {
"End": "2020-01-15",
"Start": "2020-01-01"
},
"Total": {
"BlendedCost": {
"Amount": "0",
"Unit": "USD"
},
"UnblendedCost": {
"Amount": "0",
"Unit": "USD"
},
"UsageQuantity": {
"Amount": "0",
"Unit": "N/A"
}
}
}
]
```

### Get cloudwatch metrics widgets for an instance ID

This will get the passed metric for the passed instance ID in a `image/png` graph for the past 1 day by default. It's also
possible to pass the start time, end time and period (in seconds). Query parameters must follow
This will get the passed metric(s) for the passed instance ID in a `image/png` graph for the past 1 day by default. It's also
possible to pass the start time, end time and period (e. `300s` for 300 seconds, `5m` for 5 minutes). Query parameters must follow
the [CloudWatch Metric Widget Structure](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/CloudWatch-Metric-Widget-Structure.html).

#### Request

GET /v1/cost/{account}/instances/{id}/metrics/{metric}.png
GET /v1/cost/{account}/instances/{id}/metrics/{metric}.png?start={StartTime}&end={EndTime}&period={Period}
```
GET /v1/cost/{account}/instances/{id}/metrics/graph.png?metric={metric1}[&metric={metric2}&....]
GET /v1/cost/{account}/instances/{id}/metrics/graph.png?metric={metric1}[&metric={metric2}&start={start}&end={end}&period={period}]
```

#### Response

![WidgetExample](/img/example_response.png?raw=true)

### Get cloudwatch metrics widgets URL from S3 for an instance ID

This will get the passed metric for the passed instance ID in a `image/png` graph for the past 1 day by default, cache it in S3
This will get the passed metric(s) for the passed instance ID in a `image/png` graph for the past 1 day by default, cache it in S3
and return the URL. URLs are cached in the API for 5 minutes, the images should be purged from the S3 cache on a schedule. It's also
possible to pass the start time, end time and period (in seconds). Query parameters must follow the [CloudWatch Metric Widget Structure](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/CloudWatch-Metric-Widget-Structure.html).
possible to pass the start time, end time and period (e. `300s` for 300 seconds, `5m` for 5 minutes). Query parameters must follow the [CloudWatch Metric Widget Structure](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/CloudWatch-Metric-Widget-Structure.html).

#### Request

GET /v1/cost/{account}/instances/{id}/metrics/{metric}
GET /v1/cost/{account}/instances/{id}/metrics/{metric}?start={StartTime}&end={EndTime}&period={Period}
```
GET /v1/cost/{account}/instances/{id}/metrics/graph?metric={metric1}[&metric={metric2}&....]
GET /v1/cost/{account}/instances/{id}/metrics/graph?metric={metric1}[&metric={metric2}&start={start}&end={end}&period={period}]
```

#### Response

```json
{
"ImageURL": "https://s3.amazonaws.com/sometestbucket/abc123_kLbi1SNQlKqMOmpaaJHAQZ3a-acutp5-tc6J0="
"ImageURL": "https://s3.amazonaws.com/sometestbucket/aabbccddeeff-Y3_yCKckBrkUNt3Lh4LzXBFeLXBY5IP1oUED4hyY0cdKneYelKv-xlV7K2F_d0ccwp677A=="
}
```

## Caching
Caching data (using go-cache) from AWS Cost Explorer configurable via config.json: CacheExpireTime and CachePurgeTime. The cache can also be purged via daemon restart.
## Image Caching

When image urls are returned for metrics graph data, they are cached in the image cache. The default implementation of this cache is an S3 bucket where the URLs are returned in the response (and cached in the data cache).

## Data Caching

AWS Cost Explorer data and metrics graph image url is cached (using go-cache). The cache TTLs are configurable via config.json: CacheExpireTime and CachePurgeTime. The cache can also be purged via daemon restart.

## Authentication

Expand Down
73 changes: 50 additions & 23 deletions api/handlers_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"fmt"
"net/http"
"sort"
"strings"
"time"

"github.com/YaleSpinup/cost-api/apierror"
Expand All @@ -15,17 +17,27 @@ import (
func (s *server) MetricsGetImageHandler(w http.ResponseWriter, r *http.Request) {
w = LogWriter{w}
vars := mux.Vars(r)

// get vars from the API route
account := vars["account"]
metric := vars["metric"]
id := vars["id"]

queries := r.URL.Query()
metrics := queries["metric"]
if len(metrics) == 0 {
handleError(w, apierror.New(apierror.ErrBadRequest, "at least one metric is required", nil))
return
}

period := int64(300)
// if p, ok := vars["period"]; ok {
// // TODO p is a string, need int64
// period = p
// }
if p, ok := vars["period"]; ok && p != "" {
dur, err := time.ParseDuration(p)
if err != nil {
msg := fmt.Sprintf("failed to parse period as duration: %s", err)
handleError(w, apierror.New(apierror.ErrNotFound, msg, nil))
return
}

period = int64(dur.Seconds())
}

start := "-P1D"
if s, ok := vars["start"]; ok {
Expand All @@ -45,11 +57,13 @@ func (s *server) MetricsGetImageHandler(w http.ResponseWriter, r *http.Request)
}
log.Debugf("found cloudwatch service %+v", cwService)

metrics := []cloudwatch.Metric{
cloudwatch.Metric{"AWS/EC2", metric, "InstanceId", id},
cwMetrics := []cloudwatch.Metric{}
for _, m := range metrics {
cwMetrics = append(cwMetrics, cloudwatch.Metric{"AWS/EC2", m, "InstanceId", id})
}

out, err := cwService.GetMetricWidget(r.Context(), metrics, period, start, end)
log.Debugf("getting metrics %+v, start: %s, end: %s with period: %ds", cwMetrics, start, end, period)
out, err := cwService.GetMetricWidget(r.Context(), cwMetrics, period, start, end)
if err != nil {
log.Errorf("failed getting metrics widget image: %s", err)
handleError(w, err)
Expand All @@ -65,25 +79,36 @@ func (s *server) MetricsGetImageHandler(w http.ResponseWriter, r *http.Request)
func (s *server) MetricsGetImageUrlHandler(w http.ResponseWriter, r *http.Request) {
w = LogWriter{w}
vars := mux.Vars(r)

// get vars from the API route
account := vars["account"]
metric := vars["metric"]
id := vars["id"]

queries := r.URL.Query()
metrics := queries["metric"]
if len(metrics) == 0 {
handleError(w, apierror.New(apierror.ErrBadRequest, "at least one metric is required", nil))
return
}
sort.Strings(metrics)

period := int64(300)
// // TODO p is a string, need int64
// if p, ok := vars["period"]; ok {
// period = p
// }
if p, ok := vars["period"]; ok && p != "" {
dur, err := time.ParseDuration(p)
if err != nil {
msg := fmt.Sprintf("failed to parse period as duration: %s", err)
handleError(w, apierror.New(apierror.ErrNotFound, msg, nil))
return
}

period = int64(dur.Seconds())
}

start := "-P1D"
if s, ok := vars["start"]; ok {
if s, ok := vars["start"]; ok && s != "" {
start = s
}

end := "PT0H"
if e, ok := vars["end"]; ok {
if e, ok := vars["end"]; ok && e != "" {
end = e
}

Expand All @@ -103,7 +128,7 @@ func (s *server) MetricsGetImageUrlHandler(w http.ResponseWriter, r *http.Reques
}
log.Debugf("found cost explorer result cache %+v", *resultCache)

key := fmt.Sprintf("%s/%s/%s/%s/%s/%d", Org, id, metric, start, end, period)
key := fmt.Sprintf("%s/%s/%s/%s/%s/%d", Org, id, strings.Join(metrics, "-"), start, end, period)
hashedCacheKey := s.imageCache.HashedKey(key)
if res, expire, ok := resultCache.GetWithExpiration(hashedCacheKey); ok {
log.Debugf("found cached object: %s", res)
Expand All @@ -118,11 +143,13 @@ func (s *server) MetricsGetImageUrlHandler(w http.ResponseWriter, r *http.Reques
}
}

metrics := []cloudwatch.Metric{
cloudwatch.Metric{"AWS/EC2", metric, "InstanceId", id},
cwMetrics := []cloudwatch.Metric{}
for _, m := range metrics {
cwMetrics = append(cwMetrics, cloudwatch.Metric{"AWS/EC2", m, "InstanceId", id})
}

image, err := cwService.GetMetricWidget(r.Context(), metrics, period, start, end)
log.Debugf("getting metrics %+v, start: %s, end: %s with period: %ds", cwMetrics, start, end, period)
image, err := cwService.GetMetricWidget(r.Context(), cwMetrics, period, start, end)
if err != nil {
log.Errorf("failed getting metrics widget image: %s", err)
handleError(w, err)
Expand Down
10 changes: 5 additions & 5 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ func (s *server) routes() {
api.Handle("/metrics", promhttp.Handler()).Methods(http.MethodGet)

api.HandleFunc("/{account}/spaces/{space}", s.SpaceGetHandler).
Queries("EndTime", "{EndTime}", "StartTime", "{StartTime}").Methods(http.MethodGet)
Queries("start", "{start}", "end", "{end}").Methods(http.MethodGet)
api.HandleFunc("/{account}/spaces/{space}", s.SpaceGetHandler).Methods(http.MethodGet)

api.HandleFunc("/{account}/instances/{id}/metrics/{metric}.png", s.MetricsGetImageHandler).
api.HandleFunc("/{account}/instances/{id}/metrics/graph.png", s.MetricsGetImageHandler).
Queries("period", "{period}", "start", "{start}", "end", "{end}").Methods(http.MethodGet)
api.HandleFunc("/{account}/instances/{id}/metrics/{metric}.png", s.MetricsGetImageHandler).Methods(http.MethodGet)
api.HandleFunc("/{account}/instances/{id}/metrics/{metric}", s.MetricsGetImageUrlHandler).
api.HandleFunc("/{account}/instances/{id}/metrics/graph.png", s.MetricsGetImageHandler).Methods(http.MethodGet)
api.HandleFunc("/{account}/instances/{id}/metrics/graph", s.MetricsGetImageUrlHandler).
Queries("period", "{period}", "start", "{start}", "end", "{end}").Methods(http.MethodGet)
api.HandleFunc("/{account}/instances/{id}/metrics/{metric}", s.MetricsGetImageUrlHandler).Methods(http.MethodGet)
api.HandleFunc("/{account}/instances/{id}/metrics/graph", s.MetricsGetImageUrlHandler).Methods(http.MethodGet)

}
4 changes: 2 additions & 2 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func NewServer(config common.Config) error {
return err
}

// Create a shared Cost Explorer session
// Create shared cost explorer sessions, cloudwatch sessions, and go-cache instances per account defined in the config
for name, c := range config.Accounts {
log.Debugf("creating new cost explorer service for account '%s' with key '%s' in region '%s' (org: %s)", name, c.Akid, c.Region, Org)
s.costExplorerServices[name] = costexplorer.NewSession(c)
Expand All @@ -90,7 +90,7 @@ func NewServer(config common.Config) error {
s.resultCache[name] = cache.New(expireTime, purgeTime)
}

// configure s3 image cache if specified
// if specified, configure s3 image cache
if config.ImageCache != nil {
s.imageCache = s3cache.New(config.ImageCache)
}
Expand Down

0 comments on commit 2ef3a31

Please sign in to comment.