Skip to content
This repository has been archived by the owner on Feb 1, 2024. It is now read-only.

Commit

Permalink
Add health check listener, closes #42
Browse files Browse the repository at this point in the history
  • Loading branch information
Le Cheng Fan authored and nikhilsaraf committed Nov 1, 2018
1 parent d0990ec commit c6374c3
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 4 deletions.
38 changes: 38 additions & 0 deletions cmd/trade.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/lightyeario/kelp/model"
"github.com/lightyeario/kelp/plugins"
"github.com/lightyeario/kelp/support/monitoring"
"github.com/lightyeario/kelp/support/networking"
"github.com/lightyeario/kelp/support/utils"
"github.com/lightyeario/kelp/trader"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -119,6 +120,7 @@ func init() {
// we want to delete all the offers and exit here since there is something wrong with our setup
deleteAllOffersAndExit(botConfig, client, sdex)
}

bot := trader.MakeBot(
client,
botConfig.AssetBase(),
Expand All @@ -136,6 +138,22 @@ func init() {
validateTrustlines(client, &botConfig)
log.Printf("trustlines valid\n")

// --- start initialization of services ---
if botConfig.MonitoringPort != 0 {
go func() {
e := startMonitoringServer(botConfig)
if e != nil {
log.Println()
log.Printf("unable to start the monitoring server or problem encountered while running server: %s\n", e)
// we want to delete all the offers and exit here because we don't want the bot to run if monitoring isn't working
// if monitoring is desired but not working properly, we want the bot to be shut down and guarantee that there
// aren't outstanding offers.
deleteAllOffersAndExit(botConfig, client, sdex)
}
}()
}
// --- end initialization of services ---

log.Println("Starting the trader bot...")
for {
bot.Start()
Expand All @@ -144,6 +162,26 @@ func init() {
}
}

func startMonitoringServer(botConfig trader.BotConfig) error {
healthMetrics, e := monitoring.MakeMetricsRecorder(map[string]interface{}{"success": true})
if e != nil {
return fmt.Errorf("unable to make metrics recorder for the health endpoint: %s", e)
}

healthEndpoint, e := monitoring.MakeMetricsEndpoint("/health", healthMetrics, networking.NoAuth)
if e != nil {
return fmt.Errorf("unable to make /health endpoint: %s", e)
}

server, e := networking.MakeServer([]networking.Endpoint{healthEndpoint})
if e != nil {
return fmt.Errorf("unable to initialize the metrics server: %s", e)
}

log.Printf("Starting monitoring server on port %d\n", botConfig.MonitoringPort)
return server.StartServer(botConfig.MonitoringPort, "", "")
}

func validateTrustlines(client *horizon.Client, botConfig *trader.BotConfig) {
account, e := client.LoadAccount(botConfig.TradingAccount())
if e != nil {
Expand Down
3 changes: 3 additions & 0 deletions examples/configs/trader/sample_trader.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ ISSUER_B="GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI"
TICK_INTERVAL_SECONDS=300
# the url for your horizon instance. If this url contains the string "test" then the bot assumes it is using the test network.
HORIZON_URL="https://horizon-testnet.stellar.org"

# the port that the monitoring server should run on. Uncomment the following line to add monitoring server.
# MONITORING_PORT=8081
6 changes: 4 additions & 2 deletions glide.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions glide.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ import:
- support/errors
- package: github.com/PagerDuty/go-pagerduty
version: 635c5ce271490fba94880e62cde4eea3c1c184b9
- package: github.com/go-chi/chi
version: 0ebf7795c516423a110473652e9ba3a59a504863
2 changes: 1 addition & 1 deletion support/monitoring/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ func MakeAlert(alertType string, apiKey string) (api.Alert, error) {
default:
return &noopAlert{}, nil
}
}
}
8 changes: 8 additions & 0 deletions support/monitoring/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package monitoring

// Metrics is an interface that allows a client to pass in key value pairs (keys must be strings)
// and it can dump the metrics as JSON.
type Metrics interface {
UpdateMetrics(metrics map[string]interface{})
MarshalJSON() ([]byte, error)
}
60 changes: 60 additions & 0 deletions support/monitoring/metricsEndpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package monitoring

import (
"fmt"
"log"
"net/http"
"strings"

"github.com/lightyeario/kelp/support/networking"
)

// metricsEndpoint represents a monitoring API endpoint that always responds with a JSON
// encoding of the provided metrics. The auth level for the endpoint can be NoAuth (public access)
// or GoogleAuth which uses a Google account for authorization.
type metricsEndpoint struct {
path string
metrics Metrics
authLevel networking.AuthLevel
}

// MakeMetricsEndpoint creates an Endpoint for the monitoring server with the desired auth level.
// The endpoint's response is always a JSON dump of the provided metrics.
func MakeMetricsEndpoint(path string, metrics Metrics, authLevel networking.AuthLevel) (networking.Endpoint, error) {
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf("endpoint path must begin with /")
}
s := &metricsEndpoint{
path: path,
metrics: metrics,
authLevel: authLevel,
}
return s, nil
}

func (m *metricsEndpoint) GetAuthLevel() networking.AuthLevel {
return m.authLevel
}

func (m *metricsEndpoint) GetPath() string {
return m.path
}

// GetHandlerFunc returns a HandlerFunc that writes the JSON representation of the metrics
// that's passed into the endpoint.
func (m *metricsEndpoint) GetHandlerFunc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
json, e := m.metrics.MarshalJSON()
if e != nil {
log.Printf("error marshalling metrics json: %s\n", e)
http.Error(w, e.Error(), 500)
return
}
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/json")
_, e = w.Write(json)
if e != nil {
log.Printf("error writing to the response writer: %s\n", e)
}
}
}
39 changes: 39 additions & 0 deletions support/monitoring/metricsEndpoint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package monitoring

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

"github.com/lightyeario/kelp/support/networking"
"github.com/stretchr/testify/assert"
)

func TestMetricsEndpoint_NoAuthEndpoint(t *testing.T) {
testMetrics, e := MakeMetricsRecorder(map[string]interface{}{"this is a test message": true})
if !assert.Nil(t, e) {
return
}
testEndpoint, e := MakeMetricsEndpoint("/test", testMetrics, networking.NoAuth)
if !assert.Nil(t, e) {
return
}

req, e := http.NewRequest("GET", "/test", nil)
if !assert.Nil(t, e) {
return
}
w := httptest.NewRecorder()
testEndpoint.GetHandlerFunc().ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "{\"this is a test message\":true}", w.Body.String())

// Mutate the metrics and test if the server response changes
testMetrics.UpdateMetrics(map[string]interface{}{"this is a test message": false})

w = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/test", nil)
testEndpoint.GetHandlerFunc().ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "{\"this is a test message\":false}", w.Body.String())
}
36 changes: 36 additions & 0 deletions support/monitoring/metricsRecorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package monitoring

import "encoding/json"

// MetricsRecorder uses a map to store metrics and implements the api.Metrics interface.
type metricsRecorder struct {
records map[string]interface{}
}

var _ Metrics = &metricsRecorder{}

// MakeMetricsRecorder makes a metrics recorder with the records map as the underlying map. If records
// is nil, then an empty map will be initialized for you.
func MakeMetricsRecorder(records map[string]interface{}) (Metrics, error) {
if records == nil {
return &metricsRecorder{
records: map[string]interface{}{},
}, nil
}
return &metricsRecorder{
records: records,
}, nil
}

// UpdateMetrics updates (or adds if non-existent) metrics in the records for all key-value
// pairs in the provided map of metrics.
func (m *metricsRecorder) UpdateMetrics(metrics map[string]interface{}) {
for k, v := range metrics {
m.records[k] = v
}
}

// MarshalJSON gives the JSON representation of the records.
func (m *metricsRecorder) MarshalJSON() ([]byte, error) {
return json.Marshal(m.records)
}
45 changes: 45 additions & 0 deletions support/monitoring/metricsRecorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package monitoring

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestMetricsRecorder_UpdateMetrics(t *testing.T) {
m := &metricsRecorder{
records: map[string]interface{}{},
}
m.UpdateMetrics(map[string]interface{}{
"max_qty": 100,
"volume": 200000,
"kelp_version": "1.1",
})
assert.Equal(t, 100, m.records["max_qty"])
assert.Equal(t, 200000, m.records["volume"])
assert.Equal(t, "1.1", m.records["kelp_version"])
assert.Equal(t, nil, m.records["nonexistent"])
m.UpdateMetrics(map[string]interface{}{
"max_qty": 200,
})
assert.Equal(t, 200, m.records["max_qty"])
}

func TestMetricsRecorder_MarshalJSON(t *testing.T) {
m := &metricsRecorder{
records: map[string]interface{}{},
}
m.UpdateMetrics(map[string]interface{}{
"statuses": map[string]string{
"a": "ok",
"b": "error",
},
"trade_ids": []int64{1, 2, 3, 4, 5},
"version": "10.0.1",
})
json, e := m.MarshalJSON()
if !assert.Nil(t, e) {
return
}
assert.Equal(t, `{"statuses":{"a":"ok","b":"error"},"trade_ids":[1,2,3,4,5],"version":"10.0.1"}`, string(json))
}
25 changes: 25 additions & 0 deletions support/networking/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package networking

import "net/http"

// AuthLevel specifies the level of authentication needed for an endpoint.
type AuthLevel int

const (
// NoAuth means that no authentication is required.
NoAuth AuthLevel = iota
// GoogleAuth means that a valid Google email is needed to access the endpoint.
GoogleAuth
)

// Endpoint represents an API endpoint that implements GetHandlerFunc
// which returns a http.HandlerFunc specifying the behavior when this
// endpoint is hit. It's also required to implement GetAuthLevel, which
// returns the level of authentication that's required to access this endpoint.
// Currently, the values can be NoAuth or GoogleAuth. Lastly, GetPath returns the
// path that routes to this endpoint.
type Endpoint interface {
GetHandlerFunc() http.HandlerFunc
GetAuthLevel() AuthLevel
GetPath() string
}
35 changes: 35 additions & 0 deletions support/networking/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package networking

import (
"net/http"
"strconv"
)

// WebServer defines an interface for a generic HTTP/S server with a StartServer function.
// If certFile and certKey are specified, then the server will serve through HTTPS. StartServer
// will run synchronously and return a non-nil error.
type WebServer interface {
StartServer(port uint16, certFile string, keyFile string) error
}

type server struct {
router *http.ServeMux
}

// MakeServer creates a WebServer that's responsible for serving all the endpoints passed into it.
func MakeServer(endpoints []Endpoint) (WebServer, error) {
mux := new(http.ServeMux)
s := &server{router: mux}
for _, endpoint := range endpoints {
mux.HandleFunc(endpoint.GetPath(), endpoint.GetHandlerFunc())
}
return s, nil
}

// StartServer starts the monitoring server by listening on the specified port and serving requests
// according to its handlers. If certFile and keyFile aren't empty, then the server will use TLS.
// This call will block or return a non-nil error.
func (s *server) StartServer(port uint16, certFile string, keyFile string) error {
addr := ":" + strconv.Itoa(int(port))
return http.ListenAndServe(addr, s.router)
}
2 changes: 1 addition & 1 deletion trader/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const XLM = "XLM"

// BotConfig represents the configuration params for the bot
type BotConfig struct {

SourceSecretSeed string `valid:"-" toml:"SOURCE_SECRET_SEED"`
TradingSecretSeed string `valid:"-" toml:"TRADING_SECRET_SEED"`
AssetCodeA string `valid:"-" toml:"ASSET_CODE_A"`
Expand All @@ -23,6 +22,7 @@ type BotConfig struct {
HorizonURL string `valid:"-" toml:"HORIZON_URL"`
AlertType string `valid:"-" toml:"ALERT_TYPE"`
AlertAPIKey string `valid:"-" toml:"ALERT_API_KEY"`
MonitoringPort uint16 `valid:"-" toml:"MONITORING_PORT"`

tradingAccount *string
sourceAccount *string // can be nil
Expand Down

0 comments on commit c6374c3

Please sign in to comment.