blob: fef506be2785c9b069ae11c4a2414dd018b089c7 [file] [log] [blame]
package wrapngo
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/klog/v2"
)
// metricsSet contains all the Prometheus metrics collected by wrapngo.
type metricsSet struct {
requestLatencies *prometheus.HistogramVec
waiting prometheus.GaugeFunc
inFlight prometheus.GaugeFunc
}
func newMetricsSet(ser *serializer) *metricsSet {
return &metricsSet{
requestLatencies: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "equinix_api_latency",
Help: "Equinix API request latency in seconds, partitioned by endpoint status code",
},
[]string{"endpoint", "status_code"},
),
waiting: prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "equinix_api_waiting",
Help: "Number of API requests pending to be sent to Equinix but waiting on semaphore",
},
func() float64 {
_, waiting := ser.stats()
return float64(waiting)
},
),
inFlight: prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "equinix_api_in_flight",
Help: "Number of API requests currently being processed by Equinix",
},
func() float64 {
inFlight, _ := ser.stats()
return float64(inFlight)
},
),
}
}
// getEndpointForPath converts from an Equinix API method and path (eg.
// /metal/v1/devices/deadbeef) into an 'endpoint' name, which is an imaginary,
// Monogon-specific name for the API endpoint accessed by this call.
//
// If the given path is unknown and thus cannot be converted to an endpoint name,
// 'Unknown' is return and a warning is logged.
//
// We use this function to partition request statistics per API 'endpoint'. An
// alternative to this would be to record high-level packngo function names, but
// one packngo function call might actually emit multiple HTTP API requests - so
// we're stuck recording the low-level requests and gathering statistics from
// there instead.
func getEndpointForPath(method, path string) string {
path = strings.TrimPrefix(path, "/metal/v1")
for name, match := range endpointNames {
if match.matches(method, path) {
return name
}
}
klog.Warningf("Unknown Equinix API %s %s - cannot determine metric endpoint name", method, path)
return "Unknown"
}
// requestMatch is used to match a HTTP request method/path.
type requestMatch struct {
method string
regexp *regexp.Regexp
}
func (r *requestMatch) matches(method, path string) bool {
if r.method != method {
return false
}
return r.regexp.MatchString(path)
}
var (
endpointNames = map[string]requestMatch{
"GetDevice": {"GET", regexp.MustCompile(`^/devices/[^/]+$`)},
"ListDevices": {"GET", regexp.MustCompile(`^/(organizations|projects)/[^/]+/devices$`)},
"CreateDevice": {"POST", regexp.MustCompile(`^/projects/[^/]+/devices$`)},
"ListReservations": {"GET", regexp.MustCompile(`^/projects/[^/]+/hardware-reservations$`)},
"ListSSHKeys": {"GET", regexp.MustCompile(`^/ssh-keys$`)},
"CreateSSHKey": {"POST", regexp.MustCompile(`^/project/[^/]+/ssh-keys$`)},
"GetSSHKey": {"GET", regexp.MustCompile(`^/ssh-keys/[^/]+$`)},
"UpdateSSHKey": {"PATCH", regexp.MustCompile(`^/ssh-keys/[^/]+$`)},
"PerformDeviceAction": {"POST", regexp.MustCompile(`^/devices/[^/]+/actions$`)},
}
)
// onAPIRequestDone is called by the wrapngo code on every API response from
// Equinix, and records the given parameters into metrics.
func (m *metricsSet) onAPIRequestDone(req *http.Request, res *http.Response, err error, latency time.Duration) {
if m == nil {
return
}
code := "unknown"
if err == nil {
code = fmt.Sprintf("%d", res.StatusCode)
} else {
switch {
case errors.Is(err, context.Canceled):
code = "ctx canceled"
case errors.Is(err, context.DeadlineExceeded):
code = "deadline exceeded"
}
}
if code == "unknown" {
klog.Warningf("Unexpected HTTP result: req %s %s, error: %v", req.Method, req.URL.Path, res)
}
endpoint := getEndpointForPath(req.Method, req.URL.Path)
m.requestLatencies.With(prometheus.Labels{"endpoint": endpoint, "status_code": code}).Observe(latency.Seconds())
}