mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
* Add client metrics * Add client metrics system with OpenTelemetry and VictoriaMetrics support Implements a comprehensive client metrics system to track peer connection stages and performance. The system supports multiple backend implementations (OpenTelemetry, VictoriaMetrics, and no-op) and tracks detailed connection stage durations from creation through WireGuard handshake. Key changes: - Add metrics package with pluggable backend implementations - Implement OpenTelemetry metrics backend - Implement VictoriaMetrics metrics backend - Add no-op metrics implementation for disabled state - Track connection stages: creation, semaphore, signaling, connection ready, and WireGuard handshake - Move WireGuard watcher functionality to conn.go - Refactor engine to integrate metrics tracking - Add metrics export endpoint in debug server * Add signaling metrics tracking for initial and reconnection attempts * Reset connection stage timestamps during reconnections to exclude unnecessary metrics tracking * Delete otel lib from client * Update unit tests * Invoke callback on handshake success in WireGuard watcher * Add Netbird version tracking to client metrics Integrate Netbird version into VictoriaMetrics backend and metrics labels. Update `ClientMetrics` constructor and metric name formatting to include version information. * Add sync duration tracking to client metrics Introduce `RecordSyncDuration` for measuring sync message processing time. Update all metrics implementations (VictoriaMetrics, no-op) to support the new method. Refactor `ClientMetrics` to use `AgentInfo` for static agent data. * Remove no-op metrics implementation and simplify ClientMetrics constructor Eliminate unused `noopMetrics` and refactor `ClientMetrics` to always use the VictoriaMetrics implementation. Update associated logic to reflect these changes. * Add total duration tracking for connection attempts Calculate total duration for both initial connections and reconnections, accounting for different timestamp scenarios. Update `Export` method to include Prometheus HELP comments. * Add metrics push support to VictoriaMetrics integration * [client] anchor connection metrics to first signal received * Remove creation_to_semaphore connection stage metric The semaphore queuing stage (Created → SemaphoreAcquired) is no longer tracked. Connection metrics now start from SignalingReceived. Updated docs and Grafana dashboard accordingly. * [client] Add remote push config for metrics with version-based eligibility Introduce remoteconfig.Manager that fetches a remote JSON config to control metrics push interval and restrict pushing to a specific agent version range. When NB_METRICS_INTERVAL is set, remote config is bypassed entirely for local override. * [client] Add WASM-compatible NewClientMetrics implementation Replace NewClientMetrics in metrics.go with a WASM-specific stub in metrics_js.go, returning nil for compatibility with JS builds. Simplify method usage for WASM targets. * Add missing file * Update default case in DeploymentType.String to return "unknown" instead of "selfhosted" * [client] Rework metrics to use timestamped samples instead of histograms Replace cumulative Prometheus histograms with timestamped point-in-time samples that are pushed once and cleared. This fixes metrics for sparse events (connections/syncs that happen once at startup) where rate() and increase() produced incorrect or empty results. Changes: - Switch from VictoriaMetrics histogram library to raw Prometheus text format with explicit millisecond timestamps - Reset samples after successful push (no resending stale data) - Rename connection_to_handshake → connection_to_wg_handshake - Add netbird_peer_connection_count metric for ICE vs Relay tracking - Simplify dashboard: point-based scatter plots, donut pie chart - Add maxStalenessInterval=1m to VictoriaMetrics to prevent forward-fill - Fix deployment_type Unknown returning "selfhosted" instead of "unknown" - Fix inverted shouldPush condition in push.go * [client] Add InfluxDB metrics backend alongside VictoriaMetrics Add influxdb.go with timestamped line protocol export for sparse one-shot events. Restore victoria.go to use proper Prometheus histograms. Update Grafana dashboards, add InfluxDB datasource, and update docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [client] Fix metrics issues and update dev docker setup - Fix StopPush not clearing push state, preventing restart - Fix race condition reading currentConnPriority without lock in recordConnectionMetrics - Fix stale comment referencing old metrics server URL - Update docker-compose for InfluxDB: add scoped tokens, .env config, init scripts - Rename docker-compose.victoria.yml to docker-compose.yml * [client] Add anonymised peer tracking to pushed metrics Introduce peer_id and connection_pair_id tags to InfluxDB metrics. Public keys are hashed (truncated SHA-256) for anonymisation. The connection pair ID is deterministic regardless of which side computes it, enabling deduplication of reconnections in the ICE vs Relay dashboard. Also pin Grafana to v11.6.0 for file-based provisioning and fix datasource UID references. * Remove unused dependencies from go.mod and go.sum * Refactor InfluxDB ingest pipeline: extract validation logic - Move line validation logic to `validateLine` and `validateField` helper functions. - Improve error handling with structured validation and clearer separation of concerns. - Add stderr redirection for error messages in `create-tokens.sh`. * Set non-root user in Dockerfile for Ingest service * Fix Windows CI: command line too long * Remove Victoria metrics * Add hashed peer ID as Authorization header in metrics push * Revert influxdb in docker compose * Enable gzip compression and authorization validation for metrics push and ingest * Reducate code of complexity * Update debug documentation to include metrics.txt description * Increase `maxBodySize` limit to 50 MB and update gzip reader wrapping logic * Refactor deployment type detection to use URL parsing for improved accuracy * Update readme * Throttle remote config retries on fetch failure * Preserve first WG handshake timestamp, ignore rekeys * Skip adding empty metrics.txt to debug bundle in debug mode * Update default metrics server URL to https://ingest.netbird.io * Atomic metrics export-and-reset to prevent sample loss between Export and Reset calls * Fix doc * Refactor Push configuration to improve clarity and enforce minimum push interval * Remove `minPushInterval` and update push interval validation logic * Revert ExportAndReset, it is acceptable data loss * Fix metrics review issues: rename env var, remove stale infra, add tests - Rename NB_METRICS_ENABLED to NB_METRICS_PUSH_ENABLED to clarify that collection is always active (for debug bundles) and only push is opt-in - Change default config URL from staging to production (ingest.netbird.io) - Delete broken Prometheus dashboard (used non-existent metric names) - Delete unused VictoriaMetrics datasource config - Replace committed .env with .env.example containing placeholder values - Wire Grafana admin credentials through env vars in docker-compose - Make metricsStages a pointer to prevent reset-vs-write race on reconnect - Fix typed-nil interface in debug bundle path (GetClientMetrics) - Use deterministic field order in InfluxDB Export (sorted keys) - Replace Authorization header with X-Peer-ID for metrics push - Fix ingest server timeout to use time.Second instead of float - Fix gzip double-close, stale comments, trim log levels - Add tests for influxdb.go and MetricsStages * Add login duration metric, ingest tag validation, and duration bounds - Add netbird_login measurement recording login/auth duration to management server, with success/failure result tag - Validate InfluxDB tags against per-measurement allowlists in ingest server to prevent arbitrary tag injection - Cap all duration fields (*_seconds) at 300s instead of only total_seconds - Add ingest server tests for tag/field validation, bounds, and auth * Add arch tag to all metrics * Fix Grafana dashboard: add arch to drop columns, add login panels * Validate NB_METRICS_SERVER_URL is an absolute HTTP(S) URL * Address review comments: fix README wording, update stale comments * Clarify env var precedence does not bypass remote config eligibility * Remove accidentally committed pprof files --------- Co-authored-by: Viktor Liu <viktor@netbird.io>
290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
package metrics
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
goversion "github.com/hashicorp/go-version"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/metrics/remoteconfig"
|
|
)
|
|
|
|
const (
|
|
// defaultPushInterval is the default interval for pushing metrics
|
|
defaultPushInterval = 5 * time.Minute
|
|
)
|
|
|
|
// defaultMetricsServerURL is used as fallback when NB_METRICS_FORCE_SENDING is true
|
|
var defaultMetricsServerURL *url.URL
|
|
|
|
func init() {
|
|
defaultMetricsServerURL, _ = url.Parse("https://ingest.netbird.io")
|
|
}
|
|
|
|
// PushConfig holds configuration for metrics push
|
|
type PushConfig struct {
|
|
// ServerAddress is the metrics server URL. If nil, uses remote config server_url.
|
|
ServerAddress *url.URL
|
|
// Interval is how often to push metrics. If 0, uses remote config interval or defaultPushInterval.
|
|
Interval time.Duration
|
|
// ForceSending skips remote configuration fetch and version checks, pushing unconditionally.
|
|
ForceSending bool
|
|
}
|
|
|
|
// PushConfigFromEnv builds a PushConfig from environment variables.
|
|
func PushConfigFromEnv() PushConfig {
|
|
config := PushConfig{}
|
|
|
|
config.ForceSending = isForceSending()
|
|
config.ServerAddress = getMetricsServerURL()
|
|
config.Interval = getMetricsInterval()
|
|
|
|
return config
|
|
}
|
|
|
|
// remoteConfigProvider abstracts remote push config fetching for testability
|
|
type remoteConfigProvider interface {
|
|
RefreshIfNeeded(ctx context.Context) *remoteconfig.Config
|
|
}
|
|
|
|
// Push handles periodic pushing of metrics
|
|
type Push struct {
|
|
metrics metricsImplementation
|
|
configManager remoteConfigProvider
|
|
agentVersion *goversion.Version
|
|
|
|
peerID string
|
|
peerMu sync.RWMutex
|
|
|
|
client *http.Client
|
|
cfgForceSending bool
|
|
cfgInterval time.Duration
|
|
cfgAddress *url.URL
|
|
}
|
|
|
|
// NewPush creates a new Push instance with configuration resolution
|
|
func NewPush(metrics metricsImplementation, configManager remoteConfigProvider, config PushConfig, agentVersion string) (*Push, error) {
|
|
var cfgInterval time.Duration
|
|
var cfgAddress *url.URL
|
|
|
|
if config.ForceSending {
|
|
cfgInterval = config.Interval
|
|
if config.Interval <= 0 {
|
|
cfgInterval = defaultPushInterval
|
|
}
|
|
|
|
cfgAddress = config.ServerAddress
|
|
if cfgAddress == nil {
|
|
cfgAddress = defaultMetricsServerURL
|
|
}
|
|
} else {
|
|
cfgAddress = config.ServerAddress
|
|
|
|
if config.Interval < 0 {
|
|
log.Warnf("negative metrics push interval %s", config.Interval)
|
|
} else {
|
|
cfgInterval = config.Interval
|
|
}
|
|
}
|
|
|
|
parsedVersion, err := goversion.NewVersion(agentVersion)
|
|
if err != nil {
|
|
if !config.ForceSending {
|
|
return nil, fmt.Errorf("parse agent version %q: %w", agentVersion, err)
|
|
}
|
|
}
|
|
|
|
return &Push{
|
|
metrics: metrics,
|
|
configManager: configManager,
|
|
agentVersion: parsedVersion,
|
|
cfgForceSending: config.ForceSending,
|
|
cfgInterval: cfgInterval,
|
|
cfgAddress: cfgAddress,
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// SetPeerID updates the hashed peer ID used for the Authorization header.
|
|
func (p *Push) SetPeerID(peerID string) {
|
|
p.peerMu.Lock()
|
|
p.peerID = peerID
|
|
p.peerMu.Unlock()
|
|
}
|
|
|
|
// Start starts the periodic push loop.
|
|
// The env interval override controls tick frequency but does not bypass remote config
|
|
// version gating. Use ForceSending to skip remote config entirely.
|
|
func (p *Push) Start(ctx context.Context) {
|
|
// Log initial state
|
|
switch {
|
|
case p.cfgForceSending:
|
|
log.Infof("started metrics push with force sending to %s, interval %s", p.cfgAddress, p.cfgInterval)
|
|
case p.cfgAddress != nil:
|
|
log.Infof("started metrics push with server URL override: %s", p.cfgAddress.String())
|
|
default:
|
|
log.Infof("started metrics push, server URL will be resolved from remote config")
|
|
}
|
|
|
|
timer := time.NewTimer(0) // fire immediately on first iteration
|
|
defer timer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Debug("stopping metrics push")
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
|
|
pushURL, interval := p.resolve(ctx)
|
|
if pushURL != "" {
|
|
if err := p.push(ctx, pushURL); err != nil {
|
|
log.Errorf("failed to push metrics: %v", err)
|
|
}
|
|
}
|
|
|
|
if interval <= 0 {
|
|
interval = defaultPushInterval
|
|
}
|
|
timer.Reset(interval)
|
|
}
|
|
}
|
|
|
|
// resolve returns the push URL and interval for the next cycle.
|
|
// Returns empty pushURL to skip this cycle.
|
|
func (p *Push) resolve(ctx context.Context) (pushURL string, interval time.Duration) {
|
|
if p.cfgForceSending {
|
|
return p.resolveServerURL(nil), p.cfgInterval
|
|
}
|
|
|
|
config := p.configManager.RefreshIfNeeded(ctx)
|
|
if config == nil {
|
|
log.Debug("no metrics push config available, waiting to retry")
|
|
return "", defaultPushInterval
|
|
}
|
|
|
|
// prefer env variables instead of remote config
|
|
if p.cfgInterval > 0 {
|
|
interval = p.cfgInterval
|
|
} else {
|
|
interval = config.Interval
|
|
}
|
|
|
|
if !isVersionInRange(p.agentVersion, config.VersionSince, config.VersionUntil) {
|
|
log.Debugf("agent version %s not in range [%s, %s), skipping metrics push",
|
|
p.agentVersion, config.VersionSince, config.VersionUntil)
|
|
return "", interval
|
|
}
|
|
|
|
pushURL = p.resolveServerURL(&config.ServerURL)
|
|
if pushURL == "" {
|
|
log.Warn("no metrics server URL available, skipping push")
|
|
}
|
|
return pushURL, interval
|
|
}
|
|
|
|
// push exports metrics and sends them to the metrics server
|
|
func (p *Push) push(ctx context.Context, pushURL string) error {
|
|
// Export metrics without clearing
|
|
var buf bytes.Buffer
|
|
if err := p.metrics.Export(&buf); err != nil {
|
|
return fmt.Errorf("export metrics: %w", err)
|
|
}
|
|
|
|
// Don't push if there are no metrics
|
|
if buf.Len() == 0 {
|
|
log.Tracef("no metrics to push")
|
|
return nil
|
|
}
|
|
|
|
// Gzip compress the body
|
|
compressed, err := gzipCompress(buf.Bytes())
|
|
if err != nil {
|
|
return fmt.Errorf("gzip compress: %w", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, "POST", pushURL, compressed)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
req.Header.Set("Content-Encoding", "gzip")
|
|
|
|
p.peerMu.RLock()
|
|
peerID := p.peerID
|
|
p.peerMu.RUnlock()
|
|
if peerID != "" {
|
|
req.Header.Set("X-Peer-ID", peerID)
|
|
}
|
|
|
|
// Send request
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send request: %w", err)
|
|
}
|
|
defer func() {
|
|
if resp.Body == nil {
|
|
return
|
|
}
|
|
if err := resp.Body.Close(); err != nil {
|
|
log.Warnf("failed to close response body: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Check response status
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("push failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
log.Debugf("successfully pushed metrics to %s", pushURL)
|
|
p.metrics.Reset()
|
|
return nil
|
|
}
|
|
|
|
// resolveServerURL determines the push URL.
|
|
// Precedence: envAddress (env var) > remote config server_url
|
|
func (p *Push) resolveServerURL(remoteServerURL *url.URL) string {
|
|
var baseURL *url.URL
|
|
if p.cfgAddress != nil {
|
|
baseURL = p.cfgAddress
|
|
} else {
|
|
baseURL = remoteServerURL
|
|
}
|
|
|
|
if baseURL == nil {
|
|
return ""
|
|
}
|
|
|
|
return baseURL.String()
|
|
}
|
|
|
|
// gzipCompress compresses data using gzip and returns the compressed buffer.
|
|
func gzipCompress(data []byte) (*bytes.Buffer, error) {
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
if _, err := gz.Write(data); err != nil {
|
|
_ = gz.Close()
|
|
return nil, err
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &buf, nil
|
|
}
|
|
|
|
// isVersionInRange checks if current falls within [since, until)
|
|
func isVersionInRange(current, since, until *goversion.Version) bool {
|
|
return !current.LessThan(since) && current.LessThan(until)
|
|
}
|