Repository: hetznercloud/hcloud-go
Pull request #155 (2020-10-22 08:16:42)

Expose metrics data
Expose metrics data for Hetzner Cloud Servers and Load Balancers.

Closes GH-94


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 7014f78..858c72b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,6 +3,7 @@
 ## master
 
 * Add missing constants for all resource specific error codes
+* Expose metrics for Servers and Load Balancers
 
 ## v1.22.0
 
@@ -10,7 +11,7 @@
 
 ## v1.21.1
 
-* Don't send `Authorization` Header when `WithToken` was not called 
+* Don't send `Authorization` Header when `WithToken` was not called
 
 ## v1.21.0
 
diff --git a/hcloud/load_balancer.go b/hcloud/load_balancer.go
index 79fba32..6de4a36 100644
--- a/hcloud/load_balancer.go
+++ b/hcloud/load_balancer.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"net/http"
 	"net/url"
 	"strconv"
 	"time"
@@ -931,3 +932,95 @@ func (c *LoadBalancerClient) ChangeType(ctx context.Context, loadBalancer *LoadB
 	}
 	return ActionFromSchema(respBody.Action), resp, nil
 }
+
+// LoadBalancerMetricType is the type of available metrics for Load Balancers.
+type LoadBalancerMetricType string
+
+// Available types of Load Balancer metrics. See Hetzner Cloud API
+// documentation for details.
+const (
+	LoadBalancerMetricOpenConnections      LoadBalancerMetricType = "open_connections"
+	LoadBalancerMetricConnectionsPerSecond LoadBalancerMetricType = "connections_per_second"
+	LoadBalancerMetricRequestsPerSecond    LoadBalancerMetricType = "requests_per_second"
+	LoadBalancerMetricBandwidth            LoadBalancerMetricType = "bandwidth"
+)
+
+// LoadBalancerGetMetricsOpts configures the call to get metrics for a Load
+// Balancer.
+type LoadBalancerGetMetricsOpts struct {
+	Types []LoadBalancerMetricType
+	Start time.Time
+	End   time.Time
+	Step  int
+}
+
+func (o *LoadBalancerGetMetricsOpts) addQueryParams(req *http.Request) error {
+	query := req.URL.Query()
+
+	if len(o.Types) == 0 {
+		return fmt.Errorf("no metric types specified")
+	}
+	for _, typ := range o.Types {
+		query.Add("type", string(typ))
+	}
+
+	if o.Start.IsZero() {
+		return fmt.Errorf("no start time specified")
+	}
+	query.Add("start", o.Start.Format(time.RFC3339))
+
+	if o.End.IsZero() {
+		return fmt.Errorf("no end time specified")
+	}
+	query.Add("end", o.End.Format(time.RFC3339))
+
+	if o.Step > 0 {
+		query.Add("step", strconv.Itoa(o.Step))
+	}
+	req.URL.RawQuery = query.Encode()
+
+	return nil
+}
+
+// LoadBalancerMetrics contains the metrics requested for a Load Balancer.
+type LoadBalancerMetrics struct {
+	Start      time.Time
+	End        time.Time
+	Step       float64
+	TimeSeries map[string][]LoadBalancerMetricsValue
+}
+
+// LoadBalancerMetricsValue represents a single value in a time series of metrics.
+type LoadBalancerMetricsValue struct {
+	Timestamp float64
+	Value     string
+}
+
+// GetMetrics obtains metrics for a Load Balancer.
+func (c *LoadBalancerClient) GetMetrics(
+	ctx context.Context, lb *LoadBalancer, opts LoadBalancerGetMetricsOpts,
+) (*LoadBalancerMetrics, *Response, error) {
+	var respBody schema.LoadBalancerGetMetricsResponse
+
+	if lb == nil {
+		return nil, nil, fmt.Errorf("illegal argument: load balancer is nil")
+	}
+
+	path := fmt.Sprintf("/load_balancers/%d/metrics", lb.ID)
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("new request: %v", err)
+	}
+	if err := opts.addQueryParams(req); err != nil {
+		return nil, nil, fmt.Errorf("add query params: %v", err)
+	}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, nil, fmt.Errorf("get metrics: %v", err)
+	}
+	ms, err := loadBalancerMetricsFromSchema(&respBody)
+	if err != nil {
+		return nil, nil, fmt.Errorf("convert response body: %v", err)
+	}
+	return ms, resp, nil
+}
diff --git a/hcloud/schema.go b/hcloud/schema.go
index beac330..c5f5bf0 100644
--- a/hcloud/schema.go
+++ b/hcloud/schema.go
@@ -1,6 +1,7 @@
 package hcloud
 
 import (
+	"fmt"
 	"net"
 	"strconv"
 	"time"
@@ -890,3 +891,87 @@ func loadBalancerUpdateServiceOptsToSchema(opts LoadBalancerUpdateServiceOpts) s
 	}
 	return req
 }
+
+func serverMetricsFromSchema(s *schema.ServerGetMetricsResponse) (*ServerMetrics, error) {
+	ms := ServerMetrics{
+		Start: s.Metrics.Start,
+		End:   s.Metrics.End,
+		Step:  s.Metrics.Step,
+	}
+
+	timeSeries := make(map[string][]ServerMetricsValue)
+	for tsName, v := range s.Metrics.TimeSeries {
+		vals := make([]ServerMetricsValue, len(v.Values))
+
+		for i, rawVal := range v.Values {
+			var val ServerMetricsValue
+
+			tup, ok := rawVal.([]interface{})
+			if !ok {
+				return nil, fmt.Errorf("failed to convert value to tuple: %v", rawVal)
+			}
+			if len(tup) != 2 {
+				return nil, fmt.Errorf("invalid tuple size: %d: %v", len(tup), rawVal)
+			}
+			ts, ok := tup[0].(float64)
+			if !ok {
+				return nil, fmt.Errorf("convert to float64: %v", tup[0])
+			}
+			val.Timestamp = ts
+
+			v, ok := tup[1].(string)
+			if !ok {
+				return nil, fmt.Errorf("not a string: %v", tup[1])
+			}
+			val.Value = v
+			vals[i] = val
+		}
+
+		timeSeries[tsName] = vals
+	}
+	ms.TimeSeries = timeSeries
+
+	return &ms, nil
+}
+
+func loadBalancerMetricsFromSchema(s *schema.LoadBalancerGetMetricsResponse) (*LoadBalancerMetrics, error) {
+	ms := LoadBalancerMetrics{
+		Start: s.Metrics.Start,
+		End:   s.Metrics.End,
+		Step:  s.Metrics.Step,
+	}
+
+	timeSeries := make(map[string][]LoadBalancerMetricsValue)
+	for tsName, v := range s.Metrics.TimeSeries {
+		vals := make([]LoadBalancerMetricsValue, len(v.Values))
+
+		for i, rawVal := range v.Values {
+			var val LoadBalancerMetricsValue
+
+			tup, ok := rawVal.([]interface{})
+			if !ok {
+				return nil, fmt.Errorf("failed to convert value to tuple: %v", rawVal)
+			}
+			if len(tup) != 2 {
+				return nil, fmt.Errorf("invalid tuple size: %d: %v", len(tup), rawVal)
+			}
+			ts, ok := tup[0].(float64)
+			if !ok {
+				return nil, fmt.Errorf("convert to float64: %v", tup[0])
+			}
+			val.Timestamp = ts
+
+			v, ok := tup[1].(string)
+			if !ok {
+				return nil, fmt.Errorf("not a string: %v", tup[1])
+			}
+			val.Value = v
+			vals[i] = val
+		}
+
+		timeSeries[tsName] = vals
+	}
+	ms.TimeSeries = timeSeries
+
+	return &ms, nil
+}
diff --git a/hcloud/schema/load_balancer.go b/hcloud/schema/load_balancer.go
index 7c8784f..b9dbaa9 100644
--- a/hcloud/schema/load_balancer.go
+++ b/hcloud/schema/load_balancer.go
@@ -384,3 +384,20 @@ type LoadBalancerActionChangeTypeRequest struct {
 type LoadBalancerActionChangeTypeResponse struct {
 	Action Action `json:"action"`
 }
+
+// LoadBalancerGetMetricsResponse defines the schema of the response when
+// requesting metrics for a Load Balancer.
+type LoadBalancerGetMetricsResponse struct {
+	Metrics struct {
+		Start      time.Time                             `json:"start"`
+		End        time.Time                             `json:"end"`
+		Step       float64                               `json:"step"`
+		TimeSeries map[string]LoadBalancerTimeSeriesVals `json:"time_series"`
+	} `json:"metrics"`
+}
+
+// LoadBalancerTimeSeriesVals contains the values for a Load Balancer time
+// series.
+type LoadBalancerTimeSeriesVals struct {
+	Values []interface{} `json:"values"`
+}
diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go
index 2e10fb2..2a5ca13 100644
--- a/hcloud/schema/server.go
+++ b/hcloud/schema/server.go
@@ -365,3 +365,19 @@ type ServerActionChangeAliasIPsRequest struct {
 type ServerActionChangeAliasIPsResponse struct {
 	Action Action `json:"action"`
 }
+
+// ServerGetMetricsResponse defines the schema of the response when requesting
+// metrics for a server.
+type ServerGetMetricsResponse struct {
+	Metrics struct {
+		Start      time.Time                       `json:"start"`
+		End        time.Time                       `json:"end"`
+		Step       float64                         `json:"step"`
+		TimeSeries map[string]ServerTimeSeriesVals `json:"time_series"`
+	} `json:"metrics"`
+}
+
+// ServerTimeSeriesVals contains the values for a Server time series.
+type ServerTimeSeriesVals struct {
+	Values []interface{} `json:"values"`
+}
diff --git a/hcloud/server.go b/hcloud/server.go
index 772e658..5993d83 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"net/http"
 	"net/url"
 	"strconv"
 	"time"
@@ -952,3 +953,91 @@ func (c *ServerClient) ChangeAliasIPs(ctx context.Context, server *Server, opts
 	}
 	return ActionFromSchema(respBody.Action), resp, err
 }
+
+// ServerMetricType is the type of available metrics for servers.
+type ServerMetricType string
+
+// Available types of server metrics. See Hetzner Cloud API documentation for
+// details.
+const (
+	ServerMetricCPU     ServerMetricType = "cpu"
+	ServerMetricDisk    ServerMetricType = "disk"
+	ServerMetricNetwork ServerMetricType = "network"
+)
+
+// ServerGetMetricsOpts configures the call to get metrics for a Server.
+type ServerGetMetricsOpts struct {
+	Types []ServerMetricType
+	Start time.Time
+	End   time.Time
+	Step  int
+}
+
+func (o *ServerGetMetricsOpts) addQueryParams(req *http.Request) error {
+	query := req.URL.Query()
+
+	if len(o.Types) == 0 {
+		return fmt.Errorf("no metric types specified")
+	}
+	for _, typ := range o.Types {
+		query.Add("type", string(typ))
+	}
+
+	if o.Start.IsZero() {
+		return fmt.Errorf("no start time specified")
+	}
+	query.Add("start", o.Start.Format(time.RFC3339))
+
+	if o.End.IsZero() {
+		return fmt.Errorf("no end time specified")
+	}
+	query.Add("end", o.End.Format(time.RFC3339))
+
+	if o.Step > 0 {
+		query.Add("step", strconv.Itoa(o.Step))
+	}
+	req.URL.RawQuery = query.Encode()
+
+	return nil
+}
+
+// ServerMetrics contains the metrics requested for a server.
+type ServerMetrics struct {
+	Start      time.Time
+	End        time.Time
+	Step       float64
+	TimeSeries map[string][]ServerMetricsValue
+}
+
+// ServerMetricsValue represents a single value in a time series of metrics.
+type ServerMetricsValue struct {
+	Timestamp float64
+	Value     string
+}
+
+// GetMetrics obtains metrics for server.
+func (c *ServerClient) GetMetrics(ctx context.Context, server *Server, opts ServerGetMetricsOpts) (*ServerMetrics, *Response, error) {
+	var respBody schema.ServerGetMetricsResponse
+
+	if server == nil {
+		return nil, nil, fmt.Errorf("illegal argument: server is nil")
+	}
+
+	path := fmt.Sprintf("/servers/%d/metrics", server.ID)
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("new request: %v", err)
+	}
+	if err := opts.addQueryParams(req); err != nil {
+		return nil, nil, fmt.Errorf("add query params: %v", err)
+	}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, nil, fmt.Errorf("get metrics: %v", err)
+	}
+	ms, err := serverMetricsFromSchema(&respBody)
+	if err != nil {
+		return nil, nil, fmt.Errorf("convert response body: %v", err)
+	}
+	return ms, resp, nil
+}


Test patch:
diff --git a/hcloud/load_balancer_test.go b/hcloud/load_balancer_test.go
index c01be4b..c223249 100644
--- a/hcloud/load_balancer_test.go
+++ b/hcloud/load_balancer_test.go
@@ -3,8 +3,11 @@ package hcloud
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"net"
 	"net/http"
+	"net/url"
+	"strconv"
 	"testing"
 	"time"
 
@@ -1014,3 +1017,237 @@ func TestLoadBalancerClientRemoveIPTarget(t *testing.T) {
 		t.Errorf("unexpected action ID: %d", action.ID)
 	}
 }
+
+func TestLoadBalancerGetMetrics(t *testing.T) {
+	tests := []struct {
+		name        string
+		lb          *LoadBalancer
+		opts        LoadBalancerGetMetricsOpts
+		respStatus  int
+		respFn      func() schema.LoadBalancerGetMetricsResponse
+		expected    LoadBalancerMetrics
+		expectedErr string
+	}{
+		{
+			name: "all metrics",
+			lb:   &LoadBalancer{ID: 2},
+			opts: LoadBalancerGetMetricsOpts{
+				Types: []LoadBalancerMetricType{
+					LoadBalancerMetricOpenConnections,
+					LoadBalancerMetricConnectionsPerSecond,
+					LoadBalancerMetricRequestsPerSecond,
+					LoadBalancerMetricBandwidth,
+				},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			respFn: func() schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{
+							[]interface{}{1435781470.622, "42"},
+							[]interface{}{1435781471.622, "43"},
+						},
+					},
+					"connections_per_second": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "100"},
+							[]interface{}{1435781481.622, "150"},
+						},
+					},
+					"requests_per_second": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "50"},
+							[]interface{}{1435781481.622, "55"},
+						},
+					},
+					"bandwidth.in": {
+						Values: []interface{}{
+							[]interface{}{1435781490.622, "70"},
+							[]interface{}{1435781491.622, "75"},
+						},
+					},
+					"bandwidth.out": {
+						Values: []interface{}{
+							[]interface{}{1435781590.622, "60"},
+							[]interface{}{1435781591.622, "65"},
+						},
+					},
+				}
+
+				return resp
+			},
+			expected: LoadBalancerMetrics{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+				TimeSeries: map[string][]LoadBalancerMetricsValue{
+					"open_connections": {
+						{Timestamp: 1435781470.622, Value: "42"},
+						{Timestamp: 1435781471.622, Value: "43"},
+					},
+					"connections_per_second": {
+						{Timestamp: 1435781480.622, Value: "100"},
+						{Timestamp: 1435781481.622, Value: "150"},
+					},
+					"requests_per_second": {
+						{Timestamp: 1435781480.622, Value: "50"},
+						{Timestamp: 1435781481.622, Value: "55"},
+					},
+					"bandwidth.in": {
+						{Timestamp: 1435781490.622, Value: "70"},
+						{Timestamp: 1435781491.622, Value: "75"},
+					},
+					"bandwidth.out": {
+						{Timestamp: 1435781590.622, Value: "60"},
+						{Timestamp: 1435781591.622, Value: "65"},
+					},
+				},
+			},
+		},
+		{
+			name: "missing metrics types",
+			lb:   &LoadBalancer{ID: 3},
+			opts: LoadBalancerGetMetricsOpts{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "add query params: no metric types specified",
+		},
+		{
+			name: "no start time",
+			lb:   &LoadBalancer{ID: 4},
+			opts: LoadBalancerGetMetricsOpts{
+				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "add query params: no start time specified",
+		},
+		{
+			name: "no end time",
+			lb:   &LoadBalancer{ID: 5},
+			opts: LoadBalancerGetMetricsOpts{
+				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+			},
+			expectedErr: "add query params: no end time specified",
+		},
+		{
+			name: "call to backend API fails",
+			lb:   &LoadBalancer{ID: 6},
+			opts: LoadBalancerGetMetricsOpts{
+				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			respStatus:  http.StatusInternalServerError,
+			expectedErr: "get metrics: hcloud: server responded with status code 500",
+		},
+		{
+			name: "no load balancer passed",
+			opts: LoadBalancerGetMetricsOpts{
+				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "illegal argument: load balancer is nil",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			env := newTestEnv()
+			defer env.Teardown()
+
+			if tt.lb != nil {
+				path := fmt.Sprintf("/load_balancers/%d/metrics", tt.lb.ID)
+				env.Mux.HandleFunc(path, func(rw http.ResponseWriter, r *http.Request) {
+					if r.Method != "GET" {
+						t.Errorf("expected GET; got %s", r.Method)
+					}
+					opts := loadBalancerMetricsOptsFromURL(t, r.URL)
+					if !cmp.Equal(tt.opts, opts) {
+						t.Errorf("unexpected opts: url: %s\n%v", r.URL.String(), cmp.Diff(tt.opts, opts))
+					}
+
+					status := tt.respStatus
+					if status == 0 {
+						status = http.StatusOK
+					}
+					rw.WriteHeader(status)
+
+					if tt.respFn != nil {
+						resp := tt.respFn()
+						if err := json.NewEncoder(rw).Encode(resp); err != nil {
+							t.Errorf("failed to encode response: %v", err)
+						}
+					}
+				})
+			}
+
+			ctx := context.Background()
+			actual, _, err := env.Client.LoadBalancer.GetMetrics(ctx, tt.lb, tt.opts)
+			if tt.expectedErr != "" {
+				if tt.expectedErr != err.Error() {
+					t.Errorf("expected err: %v; got: %v", tt.expectedErr, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("failed to get load balancer metrics: %v", err)
+			}
+			if !cmp.Equal(&tt.expected, actual) {
+				t.Errorf("Actual metrics did not equal expected: %s", cmp.Diff(&tt.expected, actual))
+			}
+		})
+	}
+}
+
+func loadBalancerMetricsOptsFromURL(t *testing.T, u *url.URL) LoadBalancerGetMetricsOpts {
+	var opts LoadBalancerGetMetricsOpts
+
+	for k, vs := range u.Query() {
+		switch k {
+		case "type":
+			for _, v := range vs {
+				opts.Types = append(opts.Types, LoadBalancerMetricType(v))
+			}
+		case "start":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for start; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := time.Parse(time.RFC3339, vs[0])
+			if err != nil {
+				t.Errorf("parse start as RFC3339: %v", err)
+			}
+			opts.Start = v
+		case "end":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for end; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := time.Parse(time.RFC3339, vs[0])
+			if err != nil {
+				t.Errorf("parse end as RFC3339: %v", err)
+			}
+			opts.End = v
+		case "step":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for step; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := strconv.Atoi(vs[0])
+			if err != nil {
+				t.Errorf("invalid step: %v", err)
+			}
+			opts.Step = v
+		}
+	}
+
+	return opts
+}
diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go
index 6f90417..07bf82b 100644
--- a/hcloud/schema_test.go
+++ b/hcloud/schema_test.go
@@ -2244,3 +2244,344 @@ func TestLoadBalancerUpdateServiceOptsToSchema(t *testing.T) {
 		})
 	}
 }
+
+func TestServerMetricsFromSchema(t *testing.T) {
+	tests := []struct {
+		name        string
+		respFn      func() *schema.ServerGetMetricsResponse
+		expected    *ServerMetrics
+		expectedErr string
+	}{
+		{
+			name: "values not tuples",
+			respFn: func() *schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{"some value"},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "failed to convert value to tuple: some value",
+		},
+		{
+			name: "invalid tuple size",
+			respFn: func() *schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{1435781471.622, "43", "something else"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "invalid tuple size: 3: [1.435781471622e+09 43 something else]",
+		},
+		{
+			name: "invalid time stamp",
+			respFn: func() *schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{"1435781471.622", "43"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "convert to float64: 1435781471.622",
+		},
+		{
+			name: "invalid value",
+			respFn: func() *schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{1435781471.622, 43},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "not a string: 43",
+		},
+		{
+			name: "valid response",
+			respFn: func() *schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{1435781470.622, "42"},
+							[]interface{}{1435781471.622, "43"},
+						},
+					},
+					"disk.0.iops.read": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "100"},
+							[]interface{}{1435781481.622, "150"},
+						},
+					},
+					"disk.0.iops.write": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "50"},
+							[]interface{}{1435781481.622, "55"},
+						},
+					},
+					"network.0.pps.in": {
+						Values: []interface{}{
+							[]interface{}{1435781490.622, "70"},
+							[]interface{}{1435781491.622, "75"},
+						},
+					},
+					"network.0.pps.out": {
+						Values: []interface{}{
+							[]interface{}{1435781590.622, "60"},
+							[]interface{}{1435781591.622, "65"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expected: &ServerMetrics{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+				TimeSeries: map[string][]ServerMetricsValue{
+					"cpu": {
+						{Timestamp: 1435781470.622, Value: "42"},
+						{Timestamp: 1435781471.622, Value: "43"},
+					},
+					"disk.0.iops.read": {
+						{Timestamp: 1435781480.622, Value: "100"},
+						{Timestamp: 1435781481.622, Value: "150"},
+					},
+					"disk.0.iops.write": {
+						{Timestamp: 1435781480.622, Value: "50"},
+						{Timestamp: 1435781481.622, Value: "55"},
+					},
+					"network.0.pps.in": {
+						{Timestamp: 1435781490.622, Value: "70"},
+						{Timestamp: 1435781491.622, Value: "75"},
+					},
+					"network.0.pps.out": {
+						{Timestamp: 1435781590.622, Value: "60"},
+						{Timestamp: 1435781591.622, Value: "65"},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			resp := tt.respFn()
+			actual, err := serverMetricsFromSchema(resp)
+			if err != nil && tt.expectedErr == "" {
+				t.Fatalf("expected no error; got: %v", err)
+			}
+			if err != nil && tt.expectedErr != err.Error() {
+				t.Fatalf("expected error: %s; got: %v", tt.expectedErr, err)
+			}
+			if !cmp.Equal(tt.expected, actual) {
+				t.Errorf("unexpected result:\n%s", cmp.Diff(tt.expected, actual))
+			}
+		})
+	}
+
+}
+
+func TestLoadBalancerMetricsFromSchema(t *testing.T) {
+	tests := []struct {
+		name        string
+		respFn      func() *schema.LoadBalancerGetMetricsResponse
+		expected    *LoadBalancerMetrics
+		expectedErr string
+	}{
+		{
+			name: "values not tuples",
+			respFn: func() *schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{"some value"},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "failed to convert value to tuple: some value",
+		},
+		{
+			name: "invalid tuple size",
+			respFn: func() *schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{
+							[]interface{}{1435781471.622, "43", "something else"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "invalid tuple size: 3: [1.435781471622e+09 43 something else]",
+		},
+		{
+			name: "invalid time stamp",
+			respFn: func() *schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{
+							[]interface{}{"1435781471.622", "43"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "convert to float64: 1435781471.622",
+		},
+		{
+			name: "invalid value",
+			respFn: func() *schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{
+							[]interface{}{1435781471.622, 43},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expectedErr: "not a string: 43",
+		},
+		{
+			name: "valid response",
+			respFn: func() *schema.LoadBalancerGetMetricsResponse {
+				var resp schema.LoadBalancerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.LoadBalancerTimeSeriesVals{
+					"open_connections": {
+						Values: []interface{}{
+							[]interface{}{1435781470.622, "42"},
+							[]interface{}{1435781471.622, "43"},
+						},
+					},
+					"connections_per_second": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "100"},
+							[]interface{}{1435781481.622, "150"},
+						},
+					},
+					"requests_per_second": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "50"},
+							[]interface{}{1435781481.622, "55"},
+						},
+					},
+					"bandwidth.in": {
+						Values: []interface{}{
+							[]interface{}{1435781490.622, "70"},
+							[]interface{}{1435781491.622, "75"},
+						},
+					},
+					"bandwidth.out": {
+						Values: []interface{}{
+							[]interface{}{1435781590.622, "60"},
+							[]interface{}{1435781591.622, "65"},
+						},
+					},
+				}
+
+				return &resp
+			},
+			expected: &LoadBalancerMetrics{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+				TimeSeries: map[string][]LoadBalancerMetricsValue{
+					"open_connections": {
+						{Timestamp: 1435781470.622, Value: "42"},
+						{Timestamp: 1435781471.622, Value: "43"},
+					},
+					"connections_per_second": {
+						{Timestamp: 1435781480.622, Value: "100"},
+						{Timestamp: 1435781481.622, Value: "150"},
+					},
+					"requests_per_second": {
+						{Timestamp: 1435781480.622, Value: "50"},
+						{Timestamp: 1435781481.622, Value: "55"},
+					},
+					"bandwidth.in": {
+						{Timestamp: 1435781490.622, Value: "70"},
+						{Timestamp: 1435781491.622, Value: "75"},
+					},
+					"bandwidth.out": {
+						{Timestamp: 1435781590.622, Value: "60"},
+						{Timestamp: 1435781591.622, Value: "65"},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			resp := tt.respFn()
+			actual, err := loadBalancerMetricsFromSchema(resp)
+			if err != nil && tt.expectedErr == "" {
+				t.Fatalf("expected no error; got: %v", err)
+			}
+			if err != nil && tt.expectedErr != err.Error() {
+				t.Fatalf("expected error: %s; got: %v", tt.expectedErr, err)
+			}
+			if !cmp.Equal(tt.expected, actual) {
+				t.Errorf("unexpected result:\n%s", cmp.Diff(tt.expected, actual))
+			}
+		})
+	}
+}
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index aefe83e..8a5a2a4 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -3,10 +3,15 @@ package hcloud
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"net"
 	"net/http"
+	"net/url"
+	"strconv"
 	"testing"
+	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/hetznercloud/hcloud-go/hcloud/schema"
 )
 
@@ -1746,3 +1751,281 @@ func TestServerClientChangeAliasIP(t *testing.T) {
 		t.Errorf("unexpected action ID: %v", action.ID)
 	}
 }
+
+func TestServerGetMetrics(t *testing.T) {
+	tests := []struct {
+		name        string
+		server      *Server
+		opts        ServerGetMetricsOpts
+		respStatus  int
+		respFn      func() schema.ServerGetMetricsResponse
+		expected    ServerMetrics
+		expectedErr string
+	}{
+		{
+			name:   "cpu metrics",
+			server: &Server{ID: 1},
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{ServerMetricCPU},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			respFn: func() schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{1435781470.622, "42"},
+							[]interface{}{1435781471.622, "43"},
+						},
+					},
+				}
+
+				return resp
+			},
+			expected: ServerMetrics{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+				TimeSeries: map[string][]ServerMetricsValue{
+					"cpu": {
+						{Timestamp: 1435781470.622, Value: "42"},
+						{Timestamp: 1435781471.622, Value: "43"},
+					},
+				},
+			},
+		},
+		{
+			name:   "all metrics",
+			server: &Server{ID: 2},
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{
+					ServerMetricCPU,
+					ServerMetricDisk,
+					ServerMetricNetwork,
+				},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			respFn: func() schema.ServerGetMetricsResponse {
+				var resp schema.ServerGetMetricsResponse
+
+				resp.Metrics.Start = mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z")
+				resp.Metrics.End = mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z")
+				resp.Metrics.TimeSeries = map[string]schema.ServerTimeSeriesVals{
+					"cpu": {
+						Values: []interface{}{
+							[]interface{}{1435781470.622, "42"},
+							[]interface{}{1435781471.622, "43"},
+						},
+					},
+					"disk.0.iops.read": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "100"},
+							[]interface{}{1435781481.622, "150"},
+						},
+					},
+					"disk.0.iops.write": {
+						Values: []interface{}{
+							[]interface{}{1435781480.622, "50"},
+							[]interface{}{1435781481.622, "55"},
+						},
+					},
+					"network.0.pps.in": {
+						Values: []interface{}{
+							[]interface{}{1435781490.622, "70"},
+							[]interface{}{1435781491.622, "75"},
+						},
+					},
+					"network.0.pps.out": {
+						Values: []interface{}{
+							[]interface{}{1435781590.622, "60"},
+							[]interface{}{1435781591.622, "65"},
+						},
+					},
+				}
+
+				return resp
+			},
+			expected: ServerMetrics{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+				TimeSeries: map[string][]ServerMetricsValue{
+					"cpu": {
+						{Timestamp: 1435781470.622, Value: "42"},
+						{Timestamp: 1435781471.622, Value: "43"},
+					},
+					"disk.0.iops.read": {
+						{Timestamp: 1435781480.622, Value: "100"},
+						{Timestamp: 1435781481.622, Value: "150"},
+					},
+					"disk.0.iops.write": {
+						{Timestamp: 1435781480.622, Value: "50"},
+						{Timestamp: 1435781481.622, Value: "55"},
+					},
+					"network.0.pps.in": {
+						{Timestamp: 1435781490.622, Value: "70"},
+						{Timestamp: 1435781491.622, Value: "75"},
+					},
+					"network.0.pps.out": {
+						{Timestamp: 1435781590.622, Value: "60"},
+						{Timestamp: 1435781591.622, Value: "65"},
+					},
+				},
+			},
+		},
+		{
+			name:   "missing metrics types",
+			server: &Server{ID: 3},
+			opts: ServerGetMetricsOpts{
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "add query params: no metric types specified",
+		},
+		{
+			name:   "no start time",
+			server: &Server{ID: 4},
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{ServerMetricCPU},
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "add query params: no start time specified",
+		},
+		{
+			name:   "no end time",
+			server: &Server{ID: 5},
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{ServerMetricCPU},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+			},
+			expectedErr: "add query params: no end time specified",
+		},
+		{
+			name:   "call to backend API fails",
+			server: &Server{ID: 6},
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{ServerMetricCPU},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			respStatus:  http.StatusInternalServerError,
+			expectedErr: "get metrics: hcloud: server responded with status code 500",
+		},
+		{
+			name: "no server passed",
+			opts: ServerGetMetricsOpts{
+				Types: []ServerMetricType{ServerMetricCPU},
+				Start: mustParseTime(t, time.RFC3339, "2017-01-01T00:00:00Z"),
+				End:   mustParseTime(t, time.RFC3339, "2017-01-01T23:00:00Z"),
+			},
+			expectedErr: "illegal argument: server is nil",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			env := newTestEnv()
+			defer env.Teardown()
+
+			if tt.server != nil {
+				path := fmt.Sprintf("/servers/%d/metrics", tt.server.ID)
+				env.Mux.HandleFunc(path, func(rw http.ResponseWriter, r *http.Request) {
+					if r.Method != "GET" {
+						t.Errorf("expected GET; got %s", r.Method)
+					}
+					opts := serverMetricsOptsFromURL(t, r.URL)
+					if !cmp.Equal(tt.opts, opts) {
+						t.Errorf("unexpected opts: url: %s\n%v", r.URL.String(), cmp.Diff(tt.opts, opts))
+					}
+
+					status := tt.respStatus
+					if status == 0 {
+						status = http.StatusOK
+					}
+					rw.WriteHeader(status)
+
+					if tt.respFn != nil {
+						resp := tt.respFn()
+						if err := json.NewEncoder(rw).Encode(resp); err != nil {
+							t.Errorf("failed to encode response: %v", err)
+						}
+					}
+				})
+			}
+
+			ctx := context.Background()
+			actual, _, err := env.Client.Server.GetMetrics(ctx, tt.server, tt.opts)
+			if tt.expectedErr != "" {
+				if tt.expectedErr != err.Error() {
+					t.Errorf("expected err: %v; got: %v", tt.expectedErr, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("failed to get server metrics: %v", err)
+			}
+			if !cmp.Equal(&tt.expected, actual) {
+				t.Errorf("Actual metrics did not equal expected: %s", cmp.Diff(&tt.expected, actual))
+			}
+		})
+	}
+}
+
+func serverMetricsOptsFromURL(t *testing.T, u *url.URL) ServerGetMetricsOpts {
+	var opts ServerGetMetricsOpts
+
+	for k, vs := range u.Query() {
+		switch k {
+		case "type":
+			for _, v := range vs {
+				opts.Types = append(opts.Types, ServerMetricType(v))
+			}
+		case "start":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for start; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := time.Parse(time.RFC3339, vs[0])
+			if err != nil {
+				t.Errorf("parse start as RFC3339: %v", err)
+			}
+			opts.Start = v
+		case "end":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for end; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := time.Parse(time.RFC3339, vs[0])
+			if err != nil {
+				t.Errorf("parse end as RFC3339: %v", err)
+			}
+			opts.End = v
+		case "step":
+			if len(vs) != 1 {
+				t.Errorf("expected one value for step; got %d: %v", len(vs), vs)
+				continue
+			}
+			v, err := strconv.Atoi(vs[0])
+			if err != nil {
+				t.Errorf("invalid step: %v", err)
+			}
+			opts.Step = v
+		}
+	}
+
+	return opts
+}
+
+func mustParseTime(t *testing.T, layout, value string) time.Time {
+	t.Helper()
+
+	ts, err := time.Parse(layout, value)
+	if err != nil {
+		t.Fatalf("parse time: layout %v: value %v", layout, value)
+	}
+	return ts
+}