netbird/management/server/metrics/selfhosted.go
2024-10-28 17:52:23 +01:00

436 lines
12 KiB
Go

// Package metrics gather anonymous information about the usage of NetBird management
package metrics
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/hashicorp/go-version"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server"
nbversion "github.com/netbirdio/netbird/version"
)
const (
// PayloadEvent identifies an event type
PayloadEvent = "self-hosted stats"
// payloadEndpoint metrics defaultEndpoint to send anonymous data
payloadEndpoint = "https://metrics.netbird.io"
// defaultPushInterval default interval to push metrics
defaultPushInterval = 12 * time.Hour
// requestTimeout http request timeout
requestTimeout = 45 * time.Second
)
type getTokenResponse struct {
PublicAPIToken string `json:"public_api_token"`
}
type pushPayload struct {
APIKey string `json:"api_key"`
DistinctID string `json:"distinct_id"`
Event string `json:"event"`
Properties properties `json:"properties"`
Timestamp time.Time `json:"timestamp"`
}
// properties metrics to push
type properties map[string]interface{}
// DataSource metric data source
type DataSource interface {
GetAllAccounts(ctx context.Context) []*server.Account
GetStoreEngine() server.StoreEngine
}
// ConnManager peer connection manager that holds state for current active connections
type ConnManager interface {
GetAllConnectedPeers() map[string]struct{}
}
// Worker metrics collector and pusher
type Worker struct {
ctx context.Context
id string
idpManager string
dataSource DataSource
connManager ConnManager
startupTime time.Time
lastRun time.Time
}
// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
currentTime := time.Now()
return &Worker{
ctx: ctx,
id: id,
idpManager: idpManager,
dataSource: dataSource,
connManager: connManager,
startupTime: currentTime,
lastRun: currentTime,
}
}
// Run runs the metrics worker
func (w *Worker) Run(ctx context.Context) {
interval := getMetricsInterval(ctx)
pushTicker := time.NewTicker(interval)
for {
select {
case <-w.ctx.Done():
return
case <-pushTicker.C:
err := w.sendMetrics(ctx)
if err != nil {
log.WithContext(ctx).Error(err)
}
w.lastRun = time.Now()
}
}
}
func getMetricsInterval(ctx context.Context) time.Duration {
interval := defaultPushInterval
if os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") != "" {
newInterval, err := time.ParseDuration(os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") + "s")
if err != nil {
log.WithContext(ctx).Errorf("unable to parse NETBIRD_METRICS_INTERVAL_IN_SECONDS, using default interval %v. Error: %v", defaultPushInterval, err)
} else {
log.WithContext(ctx).Infof("using NETBIRD_METRICS_INTERVAL_IN_SECONDS %s", newInterval)
interval = newInterval
}
}
return interval
}
func (w *Worker) sendMetrics(ctx context.Context) error {
apiKey, err := getAPIKey(w.ctx)
if err != nil {
return err
}
payload := w.generatePayload(ctx, apiKey)
payloadString, err := buildMetricsPayload(payload)
if err != nil {
return err
}
httpClient := http.Client{}
exportJobReq, cancelCTX, err := createPostRequest(w.ctx, payloadEndpoint+"/capture/", payloadString)
if err != nil {
return fmt.Errorf("unable to create metrics post request %v", err)
}
defer cancelCTX()
jobResp, err := httpClient.Do(exportJobReq)
if err != nil {
return fmt.Errorf("unable to push metrics %v", err)
}
defer func() {
err = jobResp.Body.Close()
if err != nil {
log.WithContext(ctx).Errorf("error while closing update metrics response body: %v", err)
}
}()
if jobResp.StatusCode != 200 {
return fmt.Errorf("unable to push anonymous metrics, got statusCode %d", jobResp.StatusCode)
}
log.WithContext(ctx).Infof("sent anonymous metrics, next push will happen in %s. "+
"You can disable these metrics by running with flag --disable-anonymous-metrics,"+
" see more information at https://docs.netbird.io/about-netbird/faq#why-and-what-are-the-anonymous-usage-metrics", getMetricsInterval(ctx))
return nil
}
func (w *Worker) generatePayload(ctx context.Context, apiKey string) pushPayload {
properties := w.generateProperties(ctx)
return pushPayload{
APIKey: apiKey,
DistinctID: w.id,
Event: PayloadEvent,
Properties: properties,
Timestamp: time.Now(),
}
}
func (w *Worker) generateProperties(ctx context.Context) properties {
var (
uptime float64
accounts int
expirationEnabled int
users int
serviceUsers int
pats int
peers int
peersSSHEnabled int
setupKeysUsage int
ephemeralPeersSKs int
ephemeralPeersSKUsage int
activePeersLastDay int
osPeers map[string]int
userPeers int
rules int
rulesProtocol map[string]int
rulesDirection map[string]int
rulesWithSrcPostureChecks int
postureChecks int
groups int
routes int
routesWithRGGroups int
nameservers int
uiClient int
version string
peerActiveVersions []string
osUIClients map[string]int
)
start := time.Now()
metricsProperties := make(properties)
osPeers = make(map[string]int)
osUIClients = make(map[string]int)
rulesProtocol = make(map[string]int)
rulesDirection = make(map[string]int)
uptime = time.Since(w.startupTime).Seconds()
connections := w.connManager.GetAllConnectedPeers()
version = nbversion.NetbirdVersion()
for _, account := range w.dataSource.GetAllAccounts(ctx) {
accounts++
if account.Settings.PeerLoginExpirationEnabled {
expirationEnabled++
}
groups += len(account.Groups)
routes += len(account.Routes)
for _, route := range account.Routes {
if len(route.PeerGroups) > 0 {
routesWithRGGroups++
}
}
nameservers += len(account.NameServerGroups)
for _, policy := range account.Policies {
for _, rule := range policy.Rules {
rules++
rulesProtocol[string(rule.Protocol)]++
if rule.Bidirectional {
rulesDirection["bidirectional"]++
} else {
rulesDirection["oneway"]++
}
}
if len(policy.SourcePostureChecks) > 0 {
rulesWithSrcPostureChecks++
}
}
postureChecks += len(account.PostureChecks)
for _, user := range account.Users {
if user.IsServiceUser {
serviceUsers++
} else {
users++
}
pats += len(user.PATs)
}
for _, key := range account.SetupKeys {
setupKeysUsage += key.UsedTimes
if key.Ephemeral {
ephemeralPeersSKs++
ephemeralPeersSKUsage += key.UsedTimes
}
}
for _, peer := range account.Peers {
peers++
if peer.SSHEnabled {
peersSSHEnabled++
}
if peer.UserID != "" {
userPeers++
}
osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
osCount := osPeers[osKey]
osPeers[osKey] = osCount + 1
if peer.Meta.UIVersion != "" {
uiClient++
uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
osUICount := osUIClients[uiOSKey]
osUIClients[uiOSKey] = osUICount + 1
}
_, connected := connections[peer.ID]
if connected || peer.Status.LastSeen.After(w.lastRun) {
activePeersLastDay++
osActiveKey := osKey + "_active"
osActiveCount := osPeers[osActiveKey]
osPeers[osActiveKey] = osActiveCount + 1
peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
}
}
}
minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
metricsProperties["uptime"] = uptime
metricsProperties["accounts"] = accounts
metricsProperties["users"] = users
metricsProperties["service_users"] = serviceUsers
metricsProperties["pats"] = pats
metricsProperties["peers"] = peers
metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
metricsProperties["setup_keys_usage"] = setupKeysUsage
metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
metricsProperties["active_peers_last_day"] = activePeersLastDay
metricsProperties["user_peers"] = userPeers
metricsProperties["rules"] = rules
metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
metricsProperties["posture_checks"] = postureChecks
metricsProperties["groups"] = groups
metricsProperties["routes"] = routes
metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
metricsProperties["nameservers"] = nameservers
metricsProperties["version"] = version
metricsProperties["min_active_peer_version"] = minActivePeerVersion
metricsProperties["max_active_peer_version"] = maxActivePeerVersion
metricsProperties["ui_clients"] = uiClient
metricsProperties["idp_manager"] = w.idpManager
metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
for protocol, count := range rulesProtocol {
metricsProperties["rules_protocol_"+protocol] = count
}
for direction, count := range rulesDirection {
metricsProperties["rules_direction_"+direction] = count
}
for os, count := range osPeers {
metricsProperties[os] = count
}
for os, count := range osUIClients {
metricsProperties[os] = count
}
metricsProperties["metric_generation_time"] = time.Since(start).Milliseconds()
return metricsProperties
}
func getAPIKey(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
httpClient := http.Client{}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, payloadEndpoint+"/GetToken", nil)
if err != nil {
return "", fmt.Errorf("unable to create request for metrics public api token %v", err)
}
response, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("unable to request metrics public api token %v", err)
}
defer func() {
err = response.Body.Close()
if err != nil {
log.WithContext(ctx).Errorf("error while closing metrics token response body: %v", err)
}
}()
if response.StatusCode != 200 {
return "", fmt.Errorf("unable to retrieve metrics token, statusCode %d", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("coudln't get metrics token response; %v", err)
}
var tokenResponse getTokenResponse
err = json.Unmarshal(body, &tokenResponse)
if err != nil {
return "", fmt.Errorf("coudln't parse metrics public api token; %v", err)
}
return tokenResponse.PublicAPIToken, nil
}
func buildMetricsPayload(payload pushPayload) (string, error) {
str, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("unable to marshal metrics payload, got err: %v", err)
}
return string(str), nil
}
func createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
reqURL := endpoint
payload := strings.NewReader(payloadStr)
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, payload)
if err != nil {
cancel()
return nil, nil, err
}
req.Header.Add("content-type", "application/json")
return req, cancel, nil
}
func getMinMaxVersion(inputList []string) (string, string) {
versions := make([]*version.Version, 0)
for _, raw := range inputList {
if raw != "" && nbversion.SemverRegexp.MatchString(raw) {
v, err := version.NewVersion(raw)
if err == nil {
versions = append(versions, v)
}
}
}
targetIndex := 1
l := len(versions)
switch l {
case 0:
return "", ""
case targetIndex:
v := versions[targetIndex-1].String()
return v, v
default:
sort.Sort(version.Collection(versions))
return versions[targetIndex-1].String(), versions[l-1].String()
}
}