github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/telemetry/region_pusher.go (about)

     1  package telemetry
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"slices"
     7  	"strings"
     8  	"sync"
     9  	"time"
    10  
    11  	sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    12  
    13  	prom "github.com/prometheus/client_golang/prometheus"
    14  	"github.com/rs/zerolog"
    15  )
    16  
    17  const CalNilStringTerminator = "__MISSING__"
    18  
    19  // RegionPusher periodically sends telemetry data for a specific region.
    20  type RegionPusher struct {
    21  	client sm.TelemetryClient
    22  	logger zerolog.Logger
    23  
    24  	instance string
    25  	regionID int32
    26  
    27  	telemetry   map[int64]map[sm.CheckClass]map[string]*sm.CheckClassTelemetry // Indexed by local tenant ID
    28  	telemetryMu sync.Mutex
    29  
    30  	metrics RegionMetrics
    31  }
    32  
    33  type RegionMetrics struct {
    34  	pushRequestsActive   prom.Gauge
    35  	pushRequestsDuration prom.Observer
    36  	pushRequestsTotal    prom.Counter
    37  	pushRequestsError    prom.Counter
    38  
    39  	addExecutionDuration prom.Observer
    40  }
    41  
    42  // start handles region metrics before a push telemetry request.
    43  func (m *RegionMetrics) start() (start time.Time) {
    44  	m.pushRequestsActive.Inc()
    45  	m.pushRequestsTotal.Inc()
    46  
    47  	return time.Now()
    48  }
    49  
    50  // end handles region metrics after a push telemetry request.
    51  func (m *RegionMetrics) end(err error, start time.Time) {
    52  	m.pushRequestsActive.Dec()
    53  	m.pushRequestsDuration.Observe(time.Since(start).Seconds())
    54  
    55  	if err != nil {
    56  		m.pushRequestsError.Inc()
    57  	}
    58  }
    59  
    60  // NewRegionPusher builds a new RegionPusher.
    61  // Notice that the effective time span used to dictate the pace for periodic
    62  // push events will be defined based on the given time span plus a random
    63  // jitter [0,59)s.
    64  func NewRegionPusher(
    65  	ctx context.Context, timeSpan time.Duration,
    66  	client sm.TelemetryClient, logger zerolog.Logger, instance string, regionID int32,
    67  	metrics RegionMetrics, options ...RegionPusherOption,
    68  ) *RegionPusher {
    69  	tp := &RegionPusher{
    70  		client:    client,
    71  		logger:    logger,
    72  		instance:  instance,
    73  		regionID:  regionID,
    74  		telemetry: make(map[int64]map[sm.CheckClass]map[string]*sm.CheckClassTelemetry),
    75  		metrics:   metrics,
    76  	}
    77  
    78  	var opts regionPusherOptions
    79  
    80  	for _, opt := range options {
    81  		opt(&opts)
    82  	}
    83  
    84  	opts.ApplyDefaults(timeSpan)
    85  
    86  	opts.wg.Add(1)
    87  
    88  	go func() {
    89  		defer opts.wg.Done()
    90  
    91  		tp.run(ctx, opts.ticker, opts.wg)
    92  	}()
    93  
    94  	return tp
    95  }
    96  
    97  type regionPusherOptions struct {
    98  	ticker ticker
    99  	wg     *sync.WaitGroup
   100  }
   101  
   102  func (opts *regionPusherOptions) ApplyDefaults(timeSpan time.Duration) {
   103  	if opts.ticker == nil {
   104  		opts.ticker = newStdTicker(timeSpan)
   105  	}
   106  
   107  	if opts.wg == nil {
   108  		opts.wg = new(sync.WaitGroup)
   109  	}
   110  }
   111  
   112  type RegionPusherOption func(opts *regionPusherOptions)
   113  
   114  func WithTicker(t ticker) RegionPusherOption {
   115  	return func(opts *regionPusherOptions) {
   116  		opts.ticker = t
   117  	}
   118  }
   119  
   120  func WithWaitGroup(wg *sync.WaitGroup) RegionPusherOption {
   121  	return func(opts *regionPusherOptions) {
   122  		opts.wg = wg
   123  	}
   124  }
   125  
   126  func (p *RegionPusher) run(ctx context.Context, ticker ticker, wg *sync.WaitGroup) {
   127  	p.logger.Info().Msg("region pusher starting")
   128  	defer p.logger.Debug().Msg("region pusher stopped")
   129  
   130  	// TODO: We could potentially create here a pushCtx, pass that to push, and
   131  	// from push keep retrying sending the data if it fails initially until the
   132  	// pushCtx is canceled, which should happen once the ticker ticks again, to
   133  	// avoid overlapping push requests.
   134  	// By now, only retry folllowing the ticker's pace.
   135  
   136  LOOP:
   137  	for {
   138  		select {
   139  		case <-ticker.C():
   140  			p.logger.Info().Msg("pushing telemetry")
   141  
   142  			m := p.next()
   143  
   144  			wg.Add(1)
   145  			// Avoid blocking
   146  			go func() {
   147  				defer wg.Done()
   148  
   149  				p.push(m)
   150  			}()
   151  
   152  		case <-ctx.Done():
   153  			p.logger.Debug().Msg("region pusher stopping")
   154  
   155  			m := p.next()
   156  			p.push(m)
   157  			ticker.Stop()
   158  
   159  			break LOOP
   160  		}
   161  	}
   162  }
   163  
   164  // AddExecution adds a new execution to the tenant telemetry.
   165  func (p *RegionPusher) AddExecution(e Execution) {
   166  	start := time.Now()
   167  
   168  	p.telemetryMu.Lock()
   169  	defer p.telemetryMu.Unlock()
   170  
   171  	tenantTele, ok := p.telemetry[e.LocalTenantID]
   172  	if !ok {
   173  		tenantTele = make(map[sm.CheckClass]map[string]*sm.CheckClassTelemetry)
   174  		p.telemetry[e.LocalTenantID] = tenantTele
   175  	}
   176  
   177  	clTele, ok := tenantTele[e.CheckClass]
   178  	if !ok {
   179  		clTele = make(map[string]*sm.CheckClassTelemetry)
   180  		tenantTele[e.CheckClass] = clTele
   181  	}
   182  
   183  	calsKey := serializeCALs(e.CostAttributionLabels)
   184  
   185  	calTele, ok := clTele[calsKey]
   186  	if !ok {
   187  		// deserializeCals returns sorted labels and handles nil -> empty slice
   188  		calTele = &sm.CheckClassTelemetry{
   189  			CheckClass:            e.CheckClass,
   190  			CostAttributionLabels: deserializeCals(calsKey),
   191  		}
   192  		clTele[calsKey] = calTele
   193  	}
   194  
   195  	calTele.Executions++
   196  	calTele.Duration += float32(e.Duration.Seconds())
   197  	calTele.SampledExecutions += int32((e.Duration + time.Minute - 1) / time.Minute)
   198  
   199  	// measure contention for AddExecution
   200  	p.metrics.addExecutionDuration.Observe(
   201  		time.Since(start).Seconds(),
   202  	)
   203  }
   204  
   205  func (p *RegionPusher) next() sm.RegionTelemetry {
   206  	m := sm.RegionTelemetry{
   207  		Instance:  p.instance,
   208  		RegionId:  p.regionID,
   209  		Telemetry: make([]*sm.TenantTelemetry, 0, len(p.telemetry)),
   210  	}
   211  
   212  	p.telemetryMu.Lock()
   213  	defer p.telemetryMu.Unlock()
   214  
   215  	// Copy current telemetry data
   216  	for tenantID, tTele := range p.telemetry {
   217  		tenantTele := &sm.TenantTelemetry{
   218  			TenantId:  tenantID,
   219  			Telemetry: make([]*sm.CheckClassTelemetry, 0, len(tTele)),
   220  		}
   221  		for _, clTele := range tTele {
   222  			for _, calTele := range clTele {
   223  				tenantTele.Telemetry = append(tenantTele.Telemetry, &sm.CheckClassTelemetry{
   224  					CheckClass:            calTele.CheckClass,
   225  					Executions:            calTele.Executions,
   226  					Duration:              calTele.Duration,
   227  					SampledExecutions:     calTele.SampledExecutions,
   228  					CostAttributionLabels: calTele.CostAttributionLabels,
   229  				})
   230  			}
   231  		}
   232  
   233  		m.Telemetry = append(m.Telemetry, tenantTele)
   234  	}
   235  
   236  	return m
   237  }
   238  
   239  func serializeCALs(cals []sm.CostAttributionLabel) string {
   240  	if len(cals) == 0 || cals == nil {
   241  		return CalNilStringTerminator
   242  	}
   243  
   244  	sorted := make([]sm.CostAttributionLabel, len(cals))
   245  	copy(sorted, cals)
   246  	slices.SortFunc(sorted, func(a, b sm.CostAttributionLabel) int {
   247  		return strings.Compare(a.Name, b.Name)
   248  	})
   249  
   250  	parts := make([]string, len(sorted))
   251  	for i, label := range sorted {
   252  		parts[i] = fmt.Sprintf("%s=%s", label.Name, label.Value)
   253  	}
   254  
   255  	return strings.Join(parts, ",")
   256  }
   257  
   258  func deserializeCals(calKey string) []sm.CostAttributionLabel {
   259  	if calKey == CalNilStringTerminator {
   260  		return []sm.CostAttributionLabel{}
   261  	}
   262  
   263  	split := strings.Split(calKey, ",")
   264  
   265  	cals := make([]sm.CostAttributionLabel, len(split))
   266  	for i, cal := range split {
   267  		c := strings.Split(cal, "=")
   268  		cals[i] = sm.CostAttributionLabel{
   269  			Name:  c[0],
   270  			Value: c[1],
   271  		}
   272  	}
   273  
   274  	return cals
   275  }
   276  
   277  func (p *RegionPusher) push(m sm.RegionTelemetry) {
   278  	var (
   279  		r   *sm.PushTelemetryResponse
   280  		err error
   281  	)
   282  
   283  	start := p.metrics.start()
   284  
   285  	defer func() { p.metrics.end(err, start) }()
   286  
   287  	// We don't want to cancel a possibly ongoing request even if the agent
   288  	// context is done, therefore use background context
   289  	ctx := context.Background()
   290  
   291  	r, err = p.client.PushTelemetry(ctx, &m)
   292  	if err != nil {
   293  		p.logger.Err(err).Msg("error pushing telemetry")
   294  		return
   295  	}
   296  
   297  	if r.Status.Code != sm.StatusCode_OK {
   298  		// create an error so it's handled by metrics.end() on defer
   299  		err = fmt.Errorf("unexpected status code")
   300  		p.logger.Err(err).
   301  			Int32("statusCode", int32(r.Status.Code)).
   302  			Str("statusMessage", r.Status.Message).
   303  			Msg("error pushing telemetry")
   304  	}
   305  }