github.com/grafana/synthetic-monitoring-agent@v0.64.2/pkg/collector/collector_test.go (about)

     1  package collector_test
     2  
     3  import (
     4  	"context"
     5  	"strconv"
     6  	"strings"
     7  	"sync/atomic"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/go-logfmt/logfmt"
    12  	"github.com/google/uuid"
    13  	"github.com/grafana/synthetic-monitoring-agent/pkg/collector"
    14  	sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    15  	"github.com/prometheus/client_golang/prometheus"
    16  	"github.com/stretchr/testify/require"
    17  )
    18  
    19  type suppliedProbe struct {
    20  	eventTime time.Time
    21  	success   bool
    22  }
    23  
    24  func (suppliedProbe) Name() string { return "supplied" }
    25  
    26  func (p suppliedProbe) Probe(_ context.Context, _ string, registry *prometheus.Registry, logger collector.Logger) (bool, float64) {
    27  	gauge := prometheus.NewGauge(prometheus.GaugeOpts{Name: "probe_supplied_value"})
    28  	registry.MustRegister(gauge)
    29  	gauge.Set(7)
    30  
    31  	_ = logger.Log(
    32  		"level", "INFO",
    33  		"msg", "supplied probe detail",
    34  		"time", p.eventTime.Format(time.RFC3339Nano),
    35  	)
    36  
    37  	return p.success, 0.25
    38  }
    39  
    40  type untimedProbe struct{}
    41  
    42  func (untimedProbe) Name() string { return "untimed" }
    43  
    44  func (untimedProbe) Probe(_ context.Context, _ string, _ *prometheus.Registry, logger collector.Logger) (bool, float64) {
    45  	_ = logger.Log("level", "INFO", "msg", "untimed-probe-detail")
    46  
    47  	return true, 0.25
    48  }
    49  
    50  // trackedContext observes whether a child context detaches from its parent.
    51  // context.WithCancel calls AfterFunc on a parent that implements it, then calls
    52  // the returned stop function once the child is cancelled — an unexported detail
    53  // of the standard library, so a future Go release could fail this test while
    54  // Close is still correct.
    55  type trackedContext struct {
    56  	context.Context
    57  	done    chan struct{}
    58  	stopped atomic.Bool
    59  }
    60  
    61  func (c *trackedContext) Done() <-chan struct{} { return c.done }
    62  
    63  func (c *trackedContext) AfterFunc(func()) func() bool {
    64  	return func() bool {
    65  		return !c.stopped.Swap(true)
    66  	}
    67  }
    68  
    69  func TestCloseReleasesCollectorContext(t *testing.T) {
    70  	ctx := &trackedContext{
    71  		Context: context.Background(),
    72  		done:    make(chan struct{}),
    73  	}
    74  	c, err := collector.New(ctx, testCheck(), testProbe(), untimedProbe{})
    75  	require.NoError(t, err)
    76  	require.False(t, ctx.stopped.Load())
    77  
    78  	c.Close()
    79  	c.Close()
    80  
    81  	require.True(t, ctx.stopped.Load())
    82  }
    83  
    84  func TestCollectDefaultsToPrefixedUserLabels(t *testing.T) {
    85  	check := testCheck()
    86  	check.Labels = []sm.Label{{Name: "team", Value: "history"}}
    87  	c, err := collector.New(context.Background(), check, testProbe(), untimedProbe{})
    88  	require.NoError(t, err)
    89  
    90  	defer c.Close()
    91  
    92  	series, _, err := c.Collect(context.Background(), time.Now())
    93  	require.NoError(t, err)
    94  
    95  	labels := metricLabels(t, series, "sm_check_info")
    96  	require.Contains(t, labels, "label_team")
    97  	require.NotContains(t, labels, "team")
    98  }
    99  
   100  func TestCollectAllowsUnprefixedUserLabels(t *testing.T) {
   101  	check := testCheck()
   102  	check.Labels = []sm.Label{{Name: "team", Value: "history"}}
   103  	c, err := collector.New(
   104  		context.Background(),
   105  		check,
   106  		testProbe(),
   107  		untimedProbe{},
   108  		collector.WithLabelMode(sm.LabelMode_LABEL_MODE_UNPREFIXED),
   109  	)
   110  	require.NoError(t, err)
   111  
   112  	defer c.Close()
   113  
   114  	series, _, err := c.Collect(context.Background(), time.Now())
   115  	require.NoError(t, err)
   116  
   117  	labels := metricLabels(t, series, "sm_check_info")
   118  	require.Contains(t, labels, "team")
   119  	require.NotContains(t, labels, "label_team")
   120  }
   121  
   122  func TestCollectUsesLogicalEventTimeAndAgentExecutionMetadata(t *testing.T) {
   123  	eventTime := time.Date(2020, 3, 1, 12, 0, 0, 0, time.UTC)
   124  	c, err := collector.New(context.Background(), testCheck(), testProbe(), suppliedProbe{
   125  		eventTime: eventTime.Add(50 * time.Millisecond),
   126  		success:   true,
   127  	})
   128  	require.NoError(t, err)
   129  
   130  	defer c.Close()
   131  
   132  	series, streams, err := c.Collect(context.Background(), eventTime)
   133  	require.NoError(t, err)
   134  	require.NotEmpty(t, series)
   135  	require.Len(t, streams, 1)
   136  
   137  	foundMetric := false
   138  
   139  	for _, ts := range series {
   140  		for _, label := range ts.Labels {
   141  			if label.Name == "__name__" && label.Value == "probe_supplied_value" {
   142  				foundMetric = true
   143  
   144  				require.Len(t, ts.Samples, 1)
   145  				require.Equal(t, eventTime.UnixMilli(), ts.Samples[0].Timestamp)
   146  			}
   147  		}
   148  	}
   149  
   150  	require.True(t, foundMetric)
   151  
   152  	var (
   153  		executionID string
   154  		walltime    float64
   155  	)
   156  
   157  	for _, entry := range streams[0].Entries {
   158  		require.Len(t, entry.StructuredMetadata, 1)
   159  		metadata := entry.StructuredMetadata[0]
   160  		require.Equal(t, "execution_id", metadata.Name)
   161  
   162  		if executionID == "" {
   163  			executionID = metadata.Value
   164  		}
   165  
   166  		require.Equal(t, executionID, metadata.Value)
   167  
   168  		if strings.Contains(entry.Line, "msg=\"Check succeeded\"") {
   169  			walltime = logfmtFloat(t, entry.Line, "walltime_seconds")
   170  			require.Equal(t, eventTime.Add(250*time.Millisecond), entry.Timestamp)
   171  		}
   172  
   173  		if strings.Contains(entry.Line, "msg=\"Beginning check\"") {
   174  			require.Equal(t, eventTime, entry.Timestamp, "beginning-check log must carry the logical event start time")
   175  		}
   176  	}
   177  
   178  	require.NoError(t, uuid.Validate(executionID))
   179  	require.Positive(t, walltime)
   180  	require.NotEqual(t, 0.25, walltime, "walltime must measure execution, not logical event duration")
   181  }
   182  
   183  func TestCollectReturnsFailedProbeTelemetry(t *testing.T) {
   184  	eventTime := time.Date(2020, 3, 1, 12, 0, 0, 0, time.UTC)
   185  	c, err := collector.New(context.Background(), testCheck(), testProbe(), suppliedProbe{
   186  		eventTime: eventTime,
   187  		success:   false,
   188  	})
   189  	require.NoError(t, err)
   190  
   191  	defer c.Close()
   192  
   193  	series, streams, err := c.Collect(context.Background(), eventTime)
   194  	require.Error(t, err)
   195  	require.NotEmpty(t, series)
   196  	require.NotEmpty(t, streams)
   197  }
   198  
   199  func TestCollectUsesLogicalEventTimeForUntimedProbeLogs(t *testing.T) {
   200  	eventTime := time.Date(2020, 3, 1, 12, 0, 0, 0, time.UTC)
   201  	c, err := collector.New(context.Background(), testCheck(), testProbe(), untimedProbe{})
   202  	require.NoError(t, err)
   203  
   204  	defer c.Close()
   205  
   206  	_, streams, err := c.Collect(context.Background(), eventTime)
   207  	require.NoError(t, err)
   208  	require.Len(t, streams, 1)
   209  
   210  	foundUntimedLog := false
   211  
   212  	for _, entry := range streams[0].Entries {
   213  		if strings.Contains(entry.Line, "msg=untimed-probe-detail") {
   214  			foundUntimedLog = true
   215  
   216  			require.Equal(t, eventTime, entry.Timestamp, "probe logs without an explicit time must use the logical event time")
   217  		}
   218  	}
   219  
   220  	require.True(t, foundUntimedLog, "expected the injected probe log in collected streams")
   221  }
   222  
   223  func logfmtFloat(t *testing.T, line, wanted string) float64 {
   224  	t.Helper()
   225  
   226  	decoder := logfmt.NewDecoder(strings.NewReader(line))
   227  	for decoder.ScanRecord() {
   228  		for decoder.ScanKeyval() {
   229  			if string(decoder.Key()) == wanted {
   230  				value, err := strconv.ParseFloat(string(decoder.Value()), 64)
   231  				require.NoError(t, err)
   232  
   233  				return value
   234  			}
   235  		}
   236  	}
   237  
   238  	require.NoError(t, decoder.Err())
   239  	t.Fatalf("log field %q not found in %q", wanted, line)
   240  
   241  	return 0
   242  }
   243  
   244  func metricLabels(t *testing.T, series collector.TimeSeries, metricName string) map[string]string {
   245  	t.Helper()
   246  
   247  	for _, ts := range series {
   248  		labels := make(map[string]string, len(ts.Labels))
   249  		for _, label := range ts.Labels {
   250  			labels[label.Name] = label.Value
   251  		}
   252  
   253  		if labels["__name__"] == metricName {
   254  			return labels
   255  		}
   256  	}
   257  
   258  	t.Fatalf("metric %q not found", metricName)
   259  
   260  	return nil
   261  }
   262  
   263  func testCheck() sm.Check {
   264  	return sm.Check{
   265  		Id:        42,
   266  		TenantId:  1,
   267  		Target:    "https://example.com",
   268  		Job:       "collector-test",
   269  		Frequency: 60_000,
   270  		Timeout:   5_000,
   271  		Settings: sm.CheckSettings{
   272  			Http: &sm.HttpSettings{IpVersion: sm.IpVersion_V4},
   273  		},
   274  	}
   275  }
   276  
   277  func testProbe() sm.Probe {
   278  	return sm.Probe{Name: "test-probe", Region: "test-region"}
   279  }