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

     1  package scraper
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/tls"
     7  	"errors"
     8  	"flag"
     9  	"fmt"
    10  	"io"
    11  	"maps"
    12  	"net"
    13  	"net/http"
    14  	"net/http/httptest"
    15  	"os"
    16  	"path/filepath"
    17  	"slices"
    18  	"sort"
    19  	"strconv"
    20  	"strings"
    21  	"sync/atomic"
    22  	"testing"
    23  	"time"
    24  
    25  	"github.com/go-logfmt/logfmt"
    26  	"github.com/google/uuid"
    27  	logproto "github.com/grafana/loki/pkg/push"
    28  	"github.com/grafana/synthetic-monitoring-agent/internal/k6runner"
    29  	"github.com/grafana/synthetic-monitoring-agent/internal/model"
    30  	"github.com/grafana/synthetic-monitoring-agent/internal/prober"
    31  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/browser"
    32  	dnsProber "github.com/grafana/synthetic-monitoring-agent/internal/prober/dns"
    33  	grpcProber "github.com/grafana/synthetic-monitoring-agent/internal/prober/grpc"
    34  	httpProber "github.com/grafana/synthetic-monitoring-agent/internal/prober/http"
    35  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/icmp"
    36  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/logger"
    37  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/multihttp"
    38  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/scripted"
    39  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/tcp"
    40  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/traceroute"
    41  	"github.com/grafana/synthetic-monitoring-agent/internal/pusher"
    42  	"github.com/grafana/synthetic-monitoring-agent/internal/telemetry"
    43  	"github.com/grafana/synthetic-monitoring-agent/internal/testhelper"
    44  	sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    45  	"github.com/miekg/dns"
    46  	"github.com/mmcloughlin/geohash"
    47  	"github.com/prometheus/client_golang/prometheus"
    48  	dto "github.com/prometheus/client_model/go"
    49  	"github.com/prometheus/common/expfmt"
    50  	prom "github.com/prometheus/common/model"
    51  	"github.com/prometheus/prometheus/model/labels"
    52  	"github.com/prometheus/prometheus/prompb"
    53  	"github.com/prometheus/prometheus/promql/parser"
    54  	"github.com/rs/zerolog"
    55  	"github.com/stretchr/testify/require"
    56  	"google.golang.org/grpc"
    57  	"google.golang.org/grpc/credentials"
    58  	grpchealth "google.golang.org/grpc/health/grpc_health_v1"
    59  	"kernel.org/pub/linux/libs/security/libcap/cap"
    60  )
    61  
    62  var updateGolden = flag.Bool("update-golden", false, "update golden files")
    63  
    64  var metricParser = parser.NewParser(parser.Options{
    65  	EnableExperimentalFunctions:  false,
    66  	ExperimentalDurationExpr:     false,
    67  	EnableExtendedRangeSelectors: false,
    68  	EnableBinopFillModifiers:     false,
    69  })
    70  
    71  // TestValidateMetrics verify that the exposed metrics have not changed.
    72  //
    73  // It does this by setting up local targets (HTTP server, DNS server,
    74  // TCP server) that the BBE probers can run against. The resulting
    75  // metrics are then compared (only names and labels, not values) against
    76  // known outputs. If metrics are added or removed, this test will fail.
    77  //
    78  // The golden files can be updated by running:
    79  //
    80  // go test -v -race -run TestValidateMetrics ./internal/scraper/
    81  func TestValidateMetrics(t *testing.T) {
    82  	for name, setup := range validateMetricSetups() {
    83  		t.Run(name, func(t *testing.T) {
    84  			verifyProberMetrics(t, name, setup, false)
    85  		})
    86  		t.Run(name+"_basic", func(t *testing.T) {
    87  			verifyProberMetrics(t, name+"_basic", setup, true)
    88  		})
    89  	}
    90  }
    91  
    92  // verifyProberMetrics runs the specified prober against the server (if
    93  // any) started by the setup function. The resup function provides the
    94  // target as well as a clean up function that will be called once the
    95  // test ends.
    96  //
    97  // Optionally, this function will update the golden files if the
    98  // -update-golden flag was passed to the test.
    99  func verifyProberMetrics(
   100  	t *testing.T,
   101  	name string,
   102  	setup func(context.Context, *testing.T) (prober.Prober, model.Check, func()),
   103  	basicMetricsOnly bool,
   104  ) {
   105  	timeout := 10 * time.Second
   106  
   107  	ctx, cancel := context.WithTimeout(context.Background(), timeout)
   108  	defer cancel()
   109  
   110  	summaries := make(map[uint64]prometheus.Summary)
   111  	histograms := make(map[uint64]prometheus.Histogram)
   112  	logger := &testLogger{w: io.Discard}
   113  
   114  	if os.Getenv("CI") == "true" {
   115  		logger.w = os.Stdout
   116  	}
   117  
   118  	prober, check, stop := setup(ctx, t)
   119  	defer stop()
   120  
   121  	success, mfs, err := getProbeMetrics(
   122  		ctx,
   123  		prober,
   124  		check.Target,
   125  		timeout,
   126  		nil,
   127  		summaries,
   128  		histograms,
   129  		logger,
   130  		basicMetricsOnly,
   131  		"test-execution-id",
   132  		scheduledScrapeClock(time.Now()),
   133  	)
   134  
   135  	require.NoError(t, err, "probe failed")
   136  	require.Truef(t, success, "probe failed: %s", prober.Name())
   137  
   138  	fn := filepath.Join("testdata", name+".txt")
   139  
   140  	if *updateGolden {
   141  		var buf bytes.Buffer
   142  
   143  		enc := expfmt.NewEncoder(&buf, expfmt.NewFormat(expfmt.TypeTextPlain))
   144  
   145  		for _, m := range mfs {
   146  			if err := enc.Encode(m); err != nil {
   147  				t.Fatalf("encoding golden file: %s", err.Error())
   148  			}
   149  		}
   150  
   151  		fh, err := os.Create(fn)
   152  		if err != nil {
   153  			t.Fatalf("cannot create file %s: %s", fn, err.Error())
   154  		}
   155  		defer fh.Close()
   156  
   157  		if _, err := buf.WriteTo(fh); err != nil {
   158  			t.Fatalf("cannot write to file %s: %s", fn, err.Error())
   159  		}
   160  
   161  		return
   162  	}
   163  
   164  	actualMetrics := map[string]struct{}{}
   165  
   166  	for _, m := range mfs {
   167  		addMetricToIndex(m, actualMetrics)
   168  	}
   169  
   170  	expectedMetrics, err := readGoldenFile(fn)
   171  	require.NoError(t, err, "reading golden file")
   172  
   173  	require.Equal(t, expectedMetrics, actualMetrics, "maps must be equal")
   174  }
   175  
   176  func addMetricToIndex(mf *dto.MetricFamily, index map[string]struct{}) {
   177  	for _, metric := range mf.GetMetric() {
   178  		labels := make([]string, 0, len(metric.GetLabel()))
   179  		for _, label := range metric.GetLabel() {
   180  			labels = append(labels, fmt.Sprintf(`%q=%q`, label.GetName(), label.GetValue()))
   181  		}
   182  
   183  		sort.Strings(labels)
   184  		index[mf.GetName()+"{"+strings.Join(labels, ",")+"}"] = struct{}{}
   185  	}
   186  }
   187  
   188  func readGoldenFile(fn string) (map[string]struct{}, error) {
   189  	fh, err := os.Open(fn)
   190  	if err != nil {
   191  		return nil, fmt.Errorf("cannot open file %s: %w", fn, err)
   192  	}
   193  	defer fh.Close()
   194  
   195  	dec := expfmt.NewDecoder(fh, expfmt.NewFormat(expfmt.TypeTextPlain))
   196  
   197  	metrics := map[string]struct{}{}
   198  
   199  	for {
   200  		var mf dto.MetricFamily
   201  
   202  		switch err := dec.Decode(&mf); {
   203  		case errors.Is(err, io.EOF):
   204  			return metrics, nil
   205  
   206  		case err != nil:
   207  			return nil, fmt.Errorf("cannot decode golden file %s: %w", fn, err)
   208  		}
   209  
   210  		addMetricToIndex(&mf, metrics)
   211  	}
   212  }
   213  
   214  func setupPingProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   215  	check := model.Check{
   216  		Check: sm.Check{
   217  			Target:  "127.0.0.1",
   218  			Timeout: 2000,
   219  			Settings: sm.CheckSettings{
   220  				Ping: &sm.PingSettings{
   221  					IpVersion: sm.IpVersion_V4,
   222  				},
   223  			},
   224  		},
   225  	}
   226  
   227  	prober, err := icmp.NewProber(check)
   228  	if err != nil {
   229  		t.Fatalf("cannot create ICMP prober: %s", err)
   230  	}
   231  
   232  	return prober, check, func() {}
   233  }
   234  
   235  func setupHTTPProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   236  	httpSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   237  		w.WriteHeader(http.StatusOK)
   238  	}))
   239  	httpSrv.Start()
   240  
   241  	check := model.Check{
   242  		Check: sm.Check{
   243  			Target:  httpSrv.URL,
   244  			Timeout: 2000,
   245  			Settings: sm.CheckSettings{
   246  				Http: &sm.HttpSettings{
   247  					IpVersion: sm.IpVersion_V4,
   248  				},
   249  			},
   250  		},
   251  	}
   252  
   253  	var store testhelper.NoopSecretStore
   254  
   255  	prober, err := httpProber.NewProber(
   256  		ctx,
   257  		check,
   258  		zerolog.New(io.Discard),
   259  		http.Header{},
   260  		store,
   261  	)
   262  	if err != nil {
   263  		t.Fatalf("cannot create HTTP prober: %s", err)
   264  	}
   265  
   266  	return prober, check, httpSrv.Close
   267  }
   268  
   269  // TestHTTPTimeoutTelemetryContract pins the production HTTP prober behavior
   270  // that deterministic history generators must reproduce. A request that does
   271  // not receive response headers before the deadline has no HTTP status, emits
   272  // the request-error log, and reports failure at approximately the timeout.
   273  func TestHTTPTimeoutTelemetryContract(t *testing.T) {
   274  	const timeout = 75 * time.Millisecond
   275  
   276  	httpSrv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
   277  		<-req.Context().Done()
   278  	}))
   279  	defer httpSrv.Close()
   280  
   281  	check := model.Check{Check: sm.Check{
   282  		Target:  httpSrv.URL,
   283  		Timeout: timeout.Milliseconds(),
   284  		Settings: sm.CheckSettings{Http: &sm.HttpSettings{
   285  			IpVersion: sm.IpVersion_V4,
   286  		}},
   287  	}}
   288  
   289  	var store testhelper.NoopSecretStore
   290  
   291  	p, err := httpProber.NewProber(context.Background(), check, zerolog.New(io.Discard), http.Header{}, store)
   292  	require.NoError(t, err)
   293  
   294  	var logs bytes.Buffer
   295  
   296  	success, mfs, err := getProbeMetrics(
   297  		context.Background(),
   298  		p,
   299  		check.Target,
   300  		timeout,
   301  		nil,
   302  		make(map[uint64]prometheus.Summary),
   303  		make(map[uint64]prometheus.Histogram),
   304  		&testLogger{w: &logs},
   305  		false,
   306  		"timeout-contract",
   307  		scheduledScrapeClock(time.Now()),
   308  	)
   309  	require.NoError(t, err)
   310  	require.False(t, success)
   311  	require.Zero(t, metricGaugeValue(t, mfs, "probe_success", nil))
   312  	require.Zero(t, metricGaugeValue(t, mfs, "probe_http_status_code", nil))
   313  	require.Zero(t, metricGaugeValue(t, mfs, "probe_http_duration_seconds", map[string]string{"phase": "processing"}))
   314  	require.Zero(t, metricGaugeValue(t, mfs, "probe_http_duration_seconds", map[string]string{"phase": "transfer"}))
   315  	require.InDelta(t, timeout.Seconds(), metricGaugeValue(t, mfs, "probe_duration_seconds", nil), 0.050)
   316  	require.Contains(t, logs.String(), "Error for HTTP request")
   317  	require.NotContains(t, logs.String(), "Received HTTP response")
   318  }
   319  
   320  func metricGaugeValue(t *testing.T, families []*dto.MetricFamily, name string, labels map[string]string) float64 {
   321  	t.Helper()
   322  
   323  	for _, family := range families {
   324  		if family.GetName() != name {
   325  			continue
   326  		}
   327  
   328  		for _, metric := range family.GetMetric() {
   329  			matched := true
   330  
   331  			for label, value := range labels {
   332  				found := false
   333  
   334  				for _, pair := range metric.GetLabel() {
   335  					if pair.GetName() == label && pair.GetValue() == value {
   336  						found = true
   337  						break
   338  					}
   339  				}
   340  
   341  				if !found {
   342  					matched = false
   343  					break
   344  				}
   345  			}
   346  
   347  			if matched && metric.GetGauge() != nil {
   348  				return metric.GetGauge().GetValue()
   349  			}
   350  		}
   351  	}
   352  
   353  	t.Fatalf("metric %q with labels %v not found", name, labels)
   354  
   355  	return 0
   356  }
   357  
   358  func setupHTTPSSLProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   359  	httpSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   360  		w.Header().Set("Last-Modified", "Wed, 16 Apr 2026 19:00:00 GMT")
   361  		w.Header().Set("ETag", `"fixture-http-ssl"`)
   362  		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
   363  		w.WriteHeader(http.StatusOK)
   364  		_, _ = io.WriteString(w, "fixture ok")
   365  	}))
   366  	httpSrv.StartTLS()
   367  
   368  	check := model.Check{
   369  		Check: sm.Check{
   370  			Target:  httpSrv.URL,
   371  			Timeout: 2000,
   372  			Settings: sm.CheckSettings{
   373  				Http: &sm.HttpSettings{
   374  					IpVersion: sm.IpVersion_V4,
   375  					TlsConfig: &sm.TLSConfig{
   376  						InsecureSkipVerify: true,
   377  					},
   378  				},
   379  			},
   380  		},
   381  	}
   382  
   383  	var store testhelper.NoopSecretStore
   384  
   385  	prober, err := httpProber.NewProber(
   386  		ctx,
   387  		check,
   388  		zerolog.New(io.Discard),
   389  		http.Header{},
   390  		store,
   391  	)
   392  	if err != nil {
   393  		t.Fatalf("cannot create HTTP prober: %s", err)
   394  	}
   395  
   396  	return prober, check, httpSrv.Close
   397  }
   398  
   399  func setupDNSProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   400  	srv, clean := setupDNSServer(t)
   401  	check := model.Check{
   402  		Check: sm.Check{
   403  			Target:  "example.org",
   404  			Timeout: 2000,
   405  			Settings: sm.CheckSettings{
   406  				Dns: &sm.DnsSettings{
   407  					Server:     srv,
   408  					IpVersion:  sm.IpVersion_V4,
   409  					Protocol:   sm.DnsProtocol_UDP,
   410  					RecordType: sm.DnsRecordType_SOA,
   411  				},
   412  			},
   413  		},
   414  	}
   415  
   416  	prober, err := dnsProber.NewProber(check)
   417  	if err != nil {
   418  		clean()
   419  		t.Fatalf("cannot create DNS prober: %s", err)
   420  	}
   421  
   422  	return prober, check, clean
   423  }
   424  
   425  func setupTCPProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   426  	srv, clean := setupTCPServer(t)
   427  	check := model.Check{
   428  		Check: sm.Check{
   429  			Target:  srv,
   430  			Timeout: 2000,
   431  			Settings: sm.CheckSettings{
   432  				Tcp: &sm.TcpSettings{
   433  					IpVersion: sm.IpVersion_V4,
   434  				},
   435  			},
   436  		},
   437  	}
   438  
   439  	prober, err := tcp.NewProber(
   440  		ctx,
   441  		check,
   442  		zerolog.New(io.Discard))
   443  	if err != nil {
   444  		clean()
   445  		t.Fatalf("cannot create TCP prober: %s", err)
   446  	}
   447  
   448  	return prober, check, clean
   449  }
   450  
   451  func setupTCPSSLProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   452  	srv, clean := setupTCPServerWithSSL(t)
   453  	check := model.Check{
   454  		Check: sm.Check{
   455  			Target:  srv,
   456  			Timeout: 2000,
   457  			Settings: sm.CheckSettings{
   458  				Tcp: &sm.TcpSettings{
   459  					IpVersion: sm.IpVersion_V4,
   460  					Tls:       true,
   461  					TlsConfig: &sm.TLSConfig{
   462  						InsecureSkipVerify: true,
   463  					},
   464  				},
   465  			},
   466  		},
   467  	}
   468  
   469  	prober, err := tcp.NewProber(
   470  		ctx,
   471  		check,
   472  		zerolog.New(io.Discard))
   473  	if err != nil {
   474  		clean()
   475  		t.Fatalf("cannot create TCP prober: %s", err)
   476  	}
   477  
   478  	return prober, check, clean
   479  }
   480  
   481  func setupTracerouteProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   482  	checkCap := func(set *cap.Set, v cap.Value) {
   483  		if permitted, err := set.GetFlag(cap.Permitted, v); err != nil {
   484  			t.Fatalf("cannot get %s flag: %s", v, err)
   485  		} else if !permitted {
   486  			t.Skipf("traceroute cannot run, process doesn't have %s capability", v)
   487  		}
   488  	}
   489  	c := cap.GetProc()
   490  	checkCap(c, cap.NET_ADMIN)
   491  	checkCap(c, cap.NET_RAW)
   492  
   493  	check := model.Check{
   494  		Check: sm.Check{
   495  			Target: "127.0.0.1",
   496  			Settings: sm.CheckSettings{
   497  				Traceroute: &sm.TracerouteSettings{},
   498  			},
   499  		},
   500  	}
   501  
   502  	p, err := traceroute.NewProber(check, zerolog.New(io.Discard))
   503  	if err != nil {
   504  		t.Fatalf("cannot create traceroute prober %s", err)
   505  	}
   506  
   507  	return p, check, func() {}
   508  }
   509  
   510  func setupScriptedProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   511  	httpSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   512  		w.Header().Set("Content-Type", "text/plain")
   513  		w.WriteHeader(http.StatusOK)
   514  		_, _ = w.Write([]byte("scripted fixture ok"))
   515  	}))
   516  
   517  	check := model.Check{
   518  		Check: sm.Check{
   519  			Target:  httpSrv.URL,
   520  			Timeout: 2000,
   521  			Settings: sm.CheckSettings{
   522  				Scripted: &sm.ScriptedSettings{
   523  					Script: []byte(fmt.Sprintf(`
   524  import http from 'k6/http';
   525  import { check } from 'k6';
   526  
   527  export const options = {
   528    insecureSkipTLSVerify: true,
   529  };
   530  
   531  export default function() {
   532    const response = http.get(%q);
   533    check(response, {
   534      ['status is 200']: (r) => r.status === 200,
   535    });
   536  }
   537  `, httpSrv.URL)),
   538  				},
   539  			},
   540  		},
   541  	}
   542  
   543  	var (
   544  		runner k6runner.Runner
   545  		err    error
   546  	)
   547  
   548  	if k6Path := os.Getenv("K6_PATH"); k6Path != "" {
   549  		runner, err = k6runner.New(k6runner.RunnerOpts{Uri: k6Path})
   550  	} else {
   551  		runner = &testRunner{
   552  			metrics: testhelper.MustReadFile(t, "testdata/k6.dat"),
   553  			logs:    nil,
   554  		}
   555  	}
   556  
   557  	if err != nil {
   558  		t.Fatalf("creating runner: %v", err)
   559  	}
   560  
   561  	var store testhelper.NoopSecretStore
   562  
   563  	prober, err := scripted.NewProber(
   564  		ctx,
   565  		check,
   566  		zerolog.New(zerolog.NewTestWriter(t)),
   567  		runner,
   568  		store,
   569  	)
   570  	if err != nil {
   571  		t.Fatalf("cannot create scripted prober: %s", err)
   572  	}
   573  
   574  	return prober, check, httpSrv.Close
   575  }
   576  
   577  func setupMultiHTTPProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   578  	httpSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   579  		w.Header().Set("Content-Type", "application/json")
   580  		w.WriteHeader(http.StatusOK)
   581  		_, _ = w.Write([]byte(`{"status":"ok","message":"multihttp fixture ok"}`))
   582  	}))
   583  
   584  	check := model.Check{
   585  		Check: sm.Check{
   586  			Target:  httpSrv.URL,
   587  			Timeout: 2000,
   588  			Settings: sm.CheckSettings{
   589  				Multihttp: &sm.MultiHttpSettings{
   590  					Entries: []*sm.MultiHttpEntry{
   591  						{
   592  							Request: &sm.MultiHttpEntryRequest{
   593  								Method: sm.HttpMethod_GET,
   594  								Url:    httpSrv.URL,
   595  							},
   596  							Assertions: []*sm.MultiHttpEntryAssertion{
   597  								{
   598  									Type:      sm.MultiHttpEntryAssertionType_TEXT,
   599  									Subject:   sm.MultiHttpEntryAssertionSubjectVariant_HTTP_STATUS_CODE,
   600  									Condition: sm.MultiHttpEntryAssertionConditionVariant_EQUALS,
   601  									Value:     "200",
   602  								},
   603  								{
   604  									Type:      sm.MultiHttpEntryAssertionType_TEXT,
   605  									Subject:   sm.MultiHttpEntryAssertionSubjectVariant_RESPONSE_BODY,
   606  									Condition: sm.MultiHttpEntryAssertionConditionVariant_CONTAINS,
   607  									Value:     "multihttp fixture ok",
   608  								},
   609  							},
   610  						},
   611  					},
   612  				},
   613  			},
   614  		},
   615  	}
   616  
   617  	var (
   618  		runner k6runner.Runner
   619  		err    error
   620  	)
   621  
   622  	if k6Path := os.Getenv("K6_PATH"); k6Path != "" {
   623  		t.Setenv("K6_INSECURE_SKIP_TLS_VERIFY", "true")
   624  
   625  		runner, err = k6runner.New(k6runner.RunnerOpts{Uri: k6Path})
   626  	} else {
   627  		runner = &testRunner{
   628  			metrics: testhelper.MustReadFile(t, "testdata/multihttp.dat"),
   629  			logs:    nil,
   630  		}
   631  	}
   632  
   633  	if err != nil {
   634  		t.Fatalf("building k6 runner: %v", err)
   635  	}
   636  
   637  	prober, err := multihttp.NewProber(
   638  		ctx,
   639  		check,
   640  		zerolog.New(zerolog.NewTestWriter(t)),
   641  		runner,
   642  		http.Header{},
   643  		testhelper.NoopSecretStore{},
   644  	)
   645  	if err != nil {
   646  		t.Fatalf("cannot create MultiHTTP prober: %s", err)
   647  	}
   648  
   649  	return prober, check, httpSrv.Close
   650  }
   651  
   652  func setupBrowserProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   653  	mux := http.NewServeMux()
   654  	mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
   655  		w.Header().Set("Content-Type", "text/html; charset=utf-8")
   656  		_, _ = io.WriteString(w, `<!doctype html>
   657  <html>
   658    <head>
   659      <title>browser fixture</title>
   660      <link rel="stylesheet" href="/style.css">
   661      <script defer src="/app.js"></script>
   662    </head>
   663    <body>
   664      <h1 id="title">Synthetic Monitoring</h1>
   665      <p id="status">browser fixture ok</p>
   666      <button id="interact" type="button">Interact</button>
   667      <img src="/pixel.png" alt="pixel">
   668    </body>
   669  </html>`)
   670  	})
   671  	mux.HandleFunc("/style.css", func(w http.ResponseWriter, req *http.Request) {
   672  		w.Header().Set("Content-Type", "text/css")
   673  		_, _ = io.WriteString(w, "body { font-family: sans-serif; }")
   674  	})
   675  	mux.HandleFunc("/app.js", func(w http.ResponseWriter, req *http.Request) {
   676  		w.Header().Set("Content-Type", "application/javascript")
   677  		_, _ = io.WriteString(w, `window.addEventListener('load', () => {
   678    const status = document.getElementById('status');
   679    const button = document.getElementById('interact');
   680    if (status) status.textContent = 'browser fixture ready';
   681    if (button && status) {
   682      button.addEventListener('click', () => {
   683        const start = performance.now();
   684        while (performance.now() - start < 120) {
   685          // Busy wait long enough to ensure the browser runtime records an input delay.
   686        }
   687        requestAnimationFrame(() => {
   688          status.textContent = 'interaction complete';
   689        });
   690      });
   691    }
   692  });`)
   693  	})
   694  	mux.HandleFunc("/pixel.png", func(w http.ResponseWriter, req *http.Request) {
   695  		w.Header().Set("Content-Type", "image/png")
   696  		_, _ = w.Write([]byte{
   697  			0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
   698  			0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
   699  			0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
   700  			0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
   701  			0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
   702  			0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
   703  			0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92,
   704  			0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
   705  			0x44, 0xae, 0x42, 0x60, 0x82,
   706  		})
   707  	})
   708  	httpSrv := httptest.NewServer(mux)
   709  
   710  	check := model.Check{
   711  		Check: sm.Check{
   712  			Target:  httpSrv.URL,
   713  			Timeout: 10000,
   714  			Settings: sm.CheckSettings{
   715  				Browser: &sm.BrowserSettings{
   716  					Script: []byte(fmt.Sprintf(`
   717  import { browser } from 'k6/browser';
   718  import { check, sleep } from 'k6';
   719  
   720  export const options = {
   721    scenarios: {
   722      ui: {
   723        executor: 'shared-iterations',
   724        options: {
   725          browser: {
   726            type: 'chromium',
   727          },
   728        },
   729      },
   730    },
   731  };
   732  
   733  export default async function () {
   734    const context = await browser.newContext();
   735    const page = await context.newPage();
   736  
   737    try {
   738      await page.goto(%q, { waitUntil: 'networkidle' });
   739      const title = await page.locator('#title').textContent();
   740      check(title, {
   741        ['heading is present']: (value) => value === 'Synthetic Monitoring',
   742      });
   743      const button = page.locator('#interact');
   744      await button.click();
   745      await page.waitForFunction(() => {
   746        const status = document.getElementById('status');
   747        return status && status.textContent === 'interaction complete';
   748      });
   749      sleep(2);
   750    } finally {
   751      await page.close();
   752    }
   753  }
   754  `, httpSrv.URL)),
   755  				},
   756  			},
   757  		},
   758  	}
   759  
   760  	var (
   761  		runner k6runner.Runner
   762  		err    error
   763  	)
   764  
   765  	if k6Path := os.Getenv("K6_PATH"); k6Path != "" {
   766  		runner, err = k6runner.New(k6runner.RunnerOpts{Uri: k6Path})
   767  	} else {
   768  		runner = &testRunner{
   769  			metrics: testhelper.MustReadFile(t, "testdata/browser.dat"),
   770  			logs:    nil,
   771  		}
   772  	}
   773  
   774  	if err != nil {
   775  		t.Fatalf("building k6 runner: %v", err)
   776  	}
   777  
   778  	prober, err := browser.NewProber(
   779  		ctx,
   780  		check,
   781  		zerolog.New(zerolog.NewTestWriter(t)),
   782  		runner,
   783  		testhelper.NoopSecretStore{},
   784  	)
   785  	if err != nil {
   786  		t.Fatalf("cannot create scripted prober: %s", err)
   787  	}
   788  
   789  	return prober, check, httpSrv.Close
   790  }
   791  
   792  func setupGRPCProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   793  	srv, clean := setupGRPCServer(t)
   794  	check := model.Check{
   795  		Check: sm.Check{
   796  			Target:  srv,
   797  			Timeout: 2000,
   798  			Settings: sm.CheckSettings{
   799  				Grpc: &sm.GrpcSettings{
   800  					IpVersion: sm.IpVersion_V4,
   801  				},
   802  			},
   803  		},
   804  	}
   805  
   806  	prober, err := grpcProber.NewProber(
   807  		ctx,
   808  		check,
   809  		zerolog.New(io.Discard))
   810  	if err != nil {
   811  		clean()
   812  		t.Fatalf("cannot create gRPC prober: %s", err)
   813  	}
   814  
   815  	return prober, check, clean
   816  }
   817  
   818  func setupGRPCSSLProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func()) {
   819  	srv, clean := setupGRPCServerWithSSL(t)
   820  	check := model.Check{
   821  		Check: sm.Check{
   822  			Target:  srv,
   823  			Timeout: 2000,
   824  			Settings: sm.CheckSettings{
   825  				Grpc: &sm.GrpcSettings{
   826  					IpVersion: sm.IpVersion_V4,
   827  					Tls:       true,
   828  					TlsConfig: &sm.TLSConfig{
   829  						InsecureSkipVerify: true,
   830  					},
   831  				},
   832  			},
   833  		},
   834  	}
   835  
   836  	prober, err := grpcProber.NewProber(
   837  		ctx,
   838  		check,
   839  		zerolog.New(io.Discard))
   840  	if err != nil {
   841  		clean()
   842  		t.Fatalf("cannot create gRPC prober: %s", err)
   843  	}
   844  
   845  	return prober, check, clean
   846  }
   847  
   848  func setupDNSServer(t *testing.T) (string, func()) {
   849  	dnsSrv, dnsAddr := startDNSServer(":0", "udp", recursiveDNSHandler)
   850  
   851  	errCh := make(chan error)
   852  
   853  	go func() {
   854  		dnsSrv.NotifyStartedFunc = func() {
   855  			close(errCh)
   856  		}
   857  
   858  		err := dnsSrv.ActivateAndServe()
   859  		if err != nil {
   860  			errCh <- err
   861  
   862  			close(errCh)
   863  		}
   864  	}()
   865  
   866  	if err := <-errCh; err != nil {
   867  		t.Fatalf("error activating DNS server: %s", err.Error())
   868  	}
   869  
   870  	return dnsAddr.String(), func() {
   871  		err := dnsSrv.Shutdown()
   872  		if err != nil {
   873  			// this should never happen, but log it if it does
   874  			t.Fatalf("error shutting down DNS server: %s", err.Error())
   875  		}
   876  	}
   877  }
   878  
   879  // startDNSServer starts a DNS server with a given handler function on a random port.
   880  // Returns the Server object itself as well as the net.Addr corresponding to the server port.
   881  func startDNSServer(addr, protocol string, handler func(dns.ResponseWriter, *dns.Msg)) (*dns.Server, net.Addr) {
   882  	h := dns.NewServeMux()
   883  	h.HandleFunc(".", handler)
   884  
   885  	server := &dns.Server{Net: protocol, Handler: h}
   886  	switch protocol {
   887  	case "udp", "udp4", "udp6":
   888  		a, err := net.ResolveUDPAddr(server.Net, server.Addr)
   889  		if err != nil {
   890  			panic(err)
   891  		}
   892  
   893  		l, err := net.ListenUDP(server.Net, a)
   894  		if err != nil {
   895  			panic(err)
   896  		}
   897  
   898  		server.Addr = l.LocalAddr().String()
   899  		server.PacketConn = l
   900  	case "tcp", "tcp4", "tcp6":
   901  		a, err := net.ResolveTCPAddr(server.Net, server.Addr)
   902  		if err != nil {
   903  			panic(err)
   904  		}
   905  
   906  		l, err := net.ListenTCP(server.Net, a)
   907  		if err != nil {
   908  			panic(err)
   909  		}
   910  
   911  		server.Addr = l.Addr().String()
   912  		server.Listener = l
   913  	default:
   914  		panic("unknown protocol")
   915  	}
   916  
   917  	if protocol == "tcp" {
   918  		return server, server.Listener.Addr()
   919  	}
   920  
   921  	return server, server.PacketConn.LocalAddr()
   922  }
   923  
   924  func recursiveDNSHandler(w dns.ResponseWriter, r *dns.Msg) {
   925  	m := new(dns.Msg)
   926  	m.SetReply(r)
   927  
   928  	if len(r.Question) == 0 {
   929  		if err := w.WriteMsg(m); err != nil {
   930  			panic(err)
   931  		}
   932  
   933  		return
   934  	}
   935  
   936  	var answers []string
   937  
   938  	switch r.Question[0].Qtype {
   939  	case dns.TypeSOA:
   940  		answers = []string{
   941  			"example.org. 3600 IN SOA ns1.example.org. hostmaster.example.org. 2026041601 7200 3600 1209600 3600",
   942  		}
   943  	default:
   944  		answers = []string{
   945  			"example.org. 3600 IN A 127.0.0.1",
   946  			"example.org. 3600 IN A 127.0.0.2",
   947  		}
   948  	}
   949  
   950  	for _, rr := range answers {
   951  		a, err := dns.NewRR(rr)
   952  		if err != nil {
   953  			panic(err)
   954  		}
   955  
   956  		m.Answer = append(m.Answer, a)
   957  	}
   958  
   959  	if err := w.WriteMsg(m); err != nil {
   960  		panic(err)
   961  	}
   962  }
   963  
   964  func setupTCPServer(t *testing.T) (string, func()) {
   965  	ln, err := net.Listen("tcp4", ":0")
   966  	if err != nil {
   967  		t.Fatalf("Error listening on socket: %s", err)
   968  	}
   969  
   970  	done := make(chan (struct{}))
   971  
   972  	go func() {
   973  		conn, err := ln.Accept()
   974  		if err != nil {
   975  			panic(fmt.Sprintf("Error accepting on socket: %s", err))
   976  		}
   977  
   978  		defer func() {
   979  			conn.Close()
   980  			close(done)
   981  		}()
   982  
   983  		buf := make([]byte, 0, 1024)
   984  		_, _ = conn.Read(buf)
   985  	}()
   986  
   987  	return ln.Addr().String(), func() {
   988  		<-done
   989  		ln.Close()
   990  	}
   991  }
   992  
   993  func setupTCPServerWithSSL(t *testing.T) (string, func()) {
   994  	ln, err := net.Listen("tcp4", ":0")
   995  	if err != nil {
   996  		t.Fatalf("Error listening on socket: %s", err)
   997  	}
   998  
   999  	cert, err := tls.X509KeyPair(localhostCert, localhostKey)
  1000  	if err != nil {
  1001  		t.Fatalf("creating X509 key pair: %s", err.Error())
  1002  	}
  1003  
  1004  	tlsCfg := tls.Config{
  1005  		Certificates: []tls.Certificate{cert},
  1006  		NextProtos:   []string{"foo"},
  1007  	}
  1008  
  1009  	ln = tls.NewListener(ln, &tlsCfg)
  1010  
  1011  	done := make(chan (struct{}))
  1012  
  1013  	go func() {
  1014  		conn, err := ln.Accept()
  1015  		if err != nil {
  1016  			panic(fmt.Sprintf("Error accepting on socket: %s", err))
  1017  		}
  1018  
  1019  		defer func() {
  1020  			conn.Close()
  1021  			close(done)
  1022  		}()
  1023  
  1024  		buf := make([]byte, 0, 1024)
  1025  		_, _ = conn.Read(buf)
  1026  	}()
  1027  
  1028  	return ln.Addr().String(), func() {
  1029  		<-done
  1030  		ln.Close()
  1031  	}
  1032  }
  1033  
  1034  type gRPCSrv struct {
  1035  	grpchealth.HealthServer
  1036  }
  1037  
  1038  func (s *gRPCSrv) Check(
  1039  	context.Context, *grpchealth.HealthCheckRequest,
  1040  ) (*grpchealth.HealthCheckResponse, error) {
  1041  	return &grpchealth.HealthCheckResponse{
  1042  		Status: grpchealth.HealthCheckResponse_SERVING,
  1043  	}, nil
  1044  }
  1045  
  1046  func setupGRPCServer(t *testing.T) (string, func()) {
  1047  	lis, err := net.Listen("tcp4", ":0")
  1048  	if err != nil {
  1049  		t.Fatalf("Error listening on socket: %v", err)
  1050  	}
  1051  
  1052  	srv := grpc.NewServer()
  1053  	grpchealth.RegisterHealthServer(srv, &gRPCSrv{})
  1054  
  1055  	go func() {
  1056  		_ = srv.Serve(lis)
  1057  	}()
  1058  
  1059  	return lis.Addr().String(), func() {
  1060  		srv.Stop()
  1061  		lis.Close()
  1062  	}
  1063  }
  1064  
  1065  func setupGRPCServerWithSSL(t *testing.T) (string, func()) {
  1066  	lis, err := net.Listen("tcp4", ":0")
  1067  	if err != nil {
  1068  		t.Fatalf("Error listening on socket: %v", err)
  1069  	}
  1070  
  1071  	cert, err := tls.X509KeyPair(localhostCert, localhostKey)
  1072  	if err != nil {
  1073  		t.Fatalf("Error creating X509 key pair: %v", err)
  1074  	}
  1075  
  1076  	tlsCfg := tls.Config{
  1077  		Certificates: []tls.Certificate{cert},
  1078  		NextProtos:   []string{"foo"},
  1079  	}
  1080  
  1081  	srv := grpc.NewServer(
  1082  		grpc.Creds(credentials.NewTLS(&tlsCfg)),
  1083  	)
  1084  	grpchealth.RegisterHealthServer(srv, &gRPCSrv{})
  1085  
  1086  	go func() {
  1087  		_ = srv.Serve(lis)
  1088  	}()
  1089  
  1090  	return lis.Addr().String(), func() {
  1091  		srv.Stop()
  1092  		lis.Close()
  1093  	}
  1094  }
  1095  
  1096  // TestValidateLabels validates that no probe generates more metric or log
  1097  // labels than the exported constants that specify these maximums.
  1098  // This test is required to be aware if any modification extends these
  1099  // maximums. These maximums are useful to calculate how many check labels can
  1100  // be set without exceeding the Mimir and Loki limits.
  1101  func TestValidateLabels(t *testing.T) {
  1102  	testCases := map[string]struct {
  1103  		setup func(ctx context.Context, t *testing.T) (prober.Prober, model.Check, func())
  1104  	}{
  1105  		"ping": {
  1106  			setup: setupPingProbe,
  1107  		},
  1108  		"http": {
  1109  			setup: setupHTTPProbe,
  1110  		},
  1111  		"http_ssl": {
  1112  			setup: setupHTTPSSLProbe,
  1113  		},
  1114  		"dns": {
  1115  			setup: setupDNSProbe,
  1116  		},
  1117  		"tcp": {
  1118  			setup: setupTCPProbe,
  1119  		},
  1120  		"tcp_ssl": {
  1121  			setup: setupTCPSSLProbe,
  1122  		},
  1123  		"traceroute": {
  1124  			setup: setupTracerouteProbe,
  1125  		},
  1126  		"scripted": {
  1127  			setup: setupScriptedProbe,
  1128  		},
  1129  		"multihttp": {
  1130  			setup: setupMultiHTTPProbe,
  1131  		},
  1132  		"grpc": {
  1133  			setup: setupGRPCProbe,
  1134  		},
  1135  		"grpc_ssl": {
  1136  			setup: setupGRPCSSLProbe,
  1137  		},
  1138  	}
  1139  
  1140  	type maxMetricLabels struct {
  1141  		AllMetrics      int
  1142  		CheckInfoMetric int
  1143  	}
  1144  
  1145  	isCheckInfoMetric := func(labels []prompb.Label) bool {
  1146  		for _, l := range labels {
  1147  			if l.Name == "__name__" && l.Value == CheckInfoMetricName {
  1148  				return true
  1149  			}
  1150  		}
  1151  
  1152  		return false
  1153  	}
  1154  
  1155  	// maxProbeMetricLabels returns the maximum number of labels for any
  1156  	// timeseries from probeData, and also the maximum number of labels for the
  1157  	// sm_check_info metric (that should be fairly static across all checks,
  1158  	// only depends on alert sensitivity, but review all generated tss anyway).
  1159  	maxProbeMetricLabels := func(t *testing.T, tss TimeSeries) maxMetricLabels {
  1160  		max := 0
  1161  		maxCheckInfoMetric := 0
  1162  
  1163  		for _, ts := range tss {
  1164  			labels := ts.GetLabels()
  1165  
  1166  			nLabels := len(labels) - 1 // Discount special __name__ label
  1167  			if nLabels > max {
  1168  				max = nLabels
  1169  			}
  1170  			// Check for CheckInfoMetric
  1171  			if isCheckInfoMetric(labels) {
  1172  				if nLabels > maxCheckInfoMetric {
  1173  					maxCheckInfoMetric = nLabels
  1174  				}
  1175  			}
  1176  		}
  1177  
  1178  		return maxMetricLabels{
  1179  			AllMetrics:      max,
  1180  			CheckInfoMetric: maxCheckInfoMetric,
  1181  		}
  1182  	}
  1183  
  1184  	// maxProbeLogLabels returns the maximum number of labels for any
  1185  	// log stream from probeData
  1186  	maxProbeLogLabels := func(t *testing.T, ss Streams) int {
  1187  		n := 0
  1188  
  1189  		for _, s := range ss {
  1190  			labels, err := metricParser.ParseMetric(s.Labels)
  1191  			require.NoError(t, err)
  1192  
  1193  			n = max(n, labels.Len())
  1194  		}
  1195  
  1196  		return n
  1197  	}
  1198  
  1199  	timeout := 10 * time.Second
  1200  
  1201  	ctx, cancel := context.WithTimeout(context.Background(), timeout)
  1202  	defer cancel()
  1203  
  1204  	var (
  1205  		totalMaxMetricLabels    int
  1206  		totalMaxCheckInfoLabels int
  1207  		totalMaxLogLabels       int
  1208  	)
  1209  
  1210  	for name, tc := range testCases {
  1211  		t.Run(name, func(t *testing.T) {
  1212  			prober, check, stop := tc.setup(ctx, t)
  1213  			defer stop()
  1214  
  1215  			check.AlertSensitivity = "low" // Force sm_check_info metric to include alert sensitivity label
  1216  
  1217  			s := Scraper{
  1218  				checkName: "check name",
  1219  				target:    check.Target,
  1220  				logger:    testhelper.Logger(t),
  1221  				prober:    prober,
  1222  				labelsLimiter: testLabelsLimiter{
  1223  					maxMetricLabels: 100,
  1224  					maxLogLabels:    100,
  1225  				},
  1226  				labellingMode: testLabellingMode{},
  1227  				summaries:     make(map[uint64]prometheus.Summary),
  1228  				histograms:    make(map[uint64]prometheus.Histogram),
  1229  				check:         check,
  1230  				probe: sm.Probe{
  1231  					Id:        100,
  1232  					TenantId:  200,
  1233  					Name:      "probe name",
  1234  					Latitude:  -1,
  1235  					Longitude: -2,
  1236  					Region:    "region",
  1237  				},
  1238  			}
  1239  
  1240  			data, duration, err := s.collectData(context.Background(), time.Unix(int64(3141000)/1000, 0))
  1241  			require.NoError(t, err)
  1242  			require.NotNil(t, data)
  1243  			require.NotZero(t, duration)
  1244  
  1245  			metricLabels := maxProbeMetricLabels(t, data.Metrics())
  1246  			logLabels := maxProbeLogLabels(t, data.Streams())
  1247  
  1248  			if metricLabels.AllMetrics > totalMaxMetricLabels {
  1249  				totalMaxMetricLabels = metricLabels.AllMetrics
  1250  			}
  1251  
  1252  			if metricLabels.CheckInfoMetric > totalMaxCheckInfoLabels {
  1253  				totalMaxCheckInfoLabels = metricLabels.CheckInfoMetric
  1254  			}
  1255  
  1256  			if logLabels > totalMaxLogLabels {
  1257  				totalMaxLogLabels = logLabels
  1258  			}
  1259  		})
  1260  	}
  1261  
  1262  	require.Equal(t, sm.MaxAgentMetricLabels(), totalMaxMetricLabels)
  1263  	require.Equal(t, sm.MaxAgentCheckInfoLabels(), totalMaxCheckInfoLabels)
  1264  	require.Equal(t, sm.MaxAgentLogLabels(), totalMaxLogLabels)
  1265  }
  1266  
  1267  func TestMakeTimeseries(t *testing.T) {
  1268  	testTime := time.Now()
  1269  	testValue := 42.0
  1270  	testcases := map[string]struct {
  1271  		t      time.Time
  1272  		value  float64
  1273  		labels []prompb.Label
  1274  	}{
  1275  		"no labels": {
  1276  			t:      testTime,
  1277  			value:  testValue,
  1278  			labels: []prompb.Label{},
  1279  		},
  1280  		"one label": {
  1281  			t:     testTime,
  1282  			value: testValue,
  1283  			labels: []prompb.Label{
  1284  				{Name: "name", Value: "value"},
  1285  			},
  1286  		},
  1287  		"many labels": {
  1288  			t:     testTime,
  1289  			value: testValue,
  1290  			labels: []prompb.Label{
  1291  				{Name: "name 1", Value: "value 1"},
  1292  				{Name: "name 2", Value: "value 2"},
  1293  				{Name: "name 3", Value: "value 3"},
  1294  				{Name: "name 4", Value: "value 4"},
  1295  			},
  1296  		},
  1297  	}
  1298  
  1299  	for name, tc := range testcases {
  1300  		t.Run(name, func(t *testing.T) {
  1301  			actual := makeTimeseries(tc.t, tc.value, tc.labels...)
  1302  			require.Equal(t, len(tc.labels), len(actual.Labels))
  1303  			require.Equal(t, tc.labels, actual.Labels)
  1304  			require.Equal(t, 1, len(actual.Samples))
  1305  			require.Equal(t, tc.t.UnixNano()/1e6, actual.Samples[0].Timestamp)
  1306  			require.Equal(t, tc.value, actual.Samples[0].Value)
  1307  		})
  1308  	}
  1309  }
  1310  
  1311  // TestMakeTimeseriesDeduplicates verifies that makeTimeseries drops duplicate
  1312  // label names, keeping the first occurrence (Mimir rejects duplicate keys)
  1313  func TestMakeTimeseriesDeduplicates(t *testing.T) {
  1314  	ts := makeTimeseries(time.Unix(1, 0), 1,
  1315  		prompb.Label{Name: prom.MetricNameLabel, Value: "m"},
  1316  		prompb.Label{Name: "probe", Value: "user-probe"},
  1317  		prompb.Label{Name: "job", Value: "system"},
  1318  		prompb.Label{Name: "probe", Value: "system"}, // duplicate name — must be dropped
  1319  		prompb.Label{Name: "env", Value: "prod"},
  1320  	)
  1321  
  1322  	require.Equal(t, []prompb.Label{
  1323  		{Name: prom.MetricNameLabel, Value: "m"},
  1324  		{Name: "probe", Value: "user-probe"}, // first occurrence wins
  1325  		{Name: "job", Value: "system"},
  1326  		{Name: "env", Value: "prod"},
  1327  	}, ts.Labels)
  1328  }
  1329  
  1330  // TestScraperCollectDataTooManyMetricLabels verifies that the check-info label
  1331  // guard rejects a scrape whose non-system label count exceeds the tenant's
  1332  // metric-label budget, before any data is emitted.
  1333  func TestScraperCollectDataTooManyMetricLabels(t *testing.T) {
  1334  	s := Scraper{
  1335  		checkName:     "check name",
  1336  		target:        "test target",
  1337  		logger:        testhelper.Logger(t),
  1338  		prober:        testProber{},
  1339  		labelsLimiter: testLabelsLimiter{maxMetricLabels: 1, maxLogLabels: 15},
  1340  		labellingMode: testLabellingMode{},
  1341  		summaries:     make(map[uint64]prometheus.Summary),
  1342  		histograms:    make(map[uint64]prometheus.Histogram),
  1343  		check: model.Check{
  1344  			Check: sm.Check{
  1345  				Id:               1,
  1346  				TenantId:         2,
  1347  				Frequency:        2000,
  1348  				Timeout:          2000,
  1349  				Enabled:          true,
  1350  				Target:           "target name",
  1351  				Job:              "job name",
  1352  				BasicMetricsOnly: true,
  1353  				Created:          42,
  1354  				Modified:         42,
  1355  				Labels: []sm.Label{
  1356  					{Name: "a", Value: "1"},
  1357  					{Name: "b", Value: "2"},
  1358  					{Name: "c", Value: "3"},
  1359  				},
  1360  				Settings: sm.CheckSettings{Http: &sm.HttpSettings{}},
  1361  			},
  1362  		},
  1363  		probe: sm.Probe{Id: 100, TenantId: 200, Name: "probe name", Region: "REGION"},
  1364  	}
  1365  
  1366  	_, _, err := s.collectData(context.Background(), time.Unix(3141, 0))
  1367  	require.ErrorIs(t, err, errTooManyLabels)
  1368  }
  1369  
  1370  // TestScraperCollectDataFatalError verifies that the exported CollectData
  1371  // surfaces a fatal collection error (one that is not errCheckFailed) with no
  1372  // telemetry, matching its documented contract.
  1373  func TestScraperCollectDataFatalError(t *testing.T) {
  1374  	s := Scraper{
  1375  		checkName:     "check name",
  1376  		target:        "test target",
  1377  		logger:        testhelper.Logger(t),
  1378  		prober:        testProber{},
  1379  		labelsLimiter: testLabelsLimiter{maxMetricLabels: 1, maxLogLabels: 15},
  1380  		labellingMode: testLabellingMode{},
  1381  		summaries:     make(map[uint64]prometheus.Summary),
  1382  		histograms:    make(map[uint64]prometheus.Histogram),
  1383  		check: model.Check{
  1384  			Check: sm.Check{
  1385  				Id:               1,
  1386  				TenantId:         2,
  1387  				Frequency:        2000,
  1388  				Timeout:          2000,
  1389  				Enabled:          true,
  1390  				Target:           "target name",
  1391  				Job:              "job name",
  1392  				BasicMetricsOnly: true,
  1393  				Created:          42,
  1394  				Modified:         42,
  1395  				Labels: []sm.Label{
  1396  					{Name: "a", Value: "1"},
  1397  					{Name: "b", Value: "2"},
  1398  					{Name: "c", Value: "3"},
  1399  				},
  1400  				Settings: sm.CheckSettings{Http: &sm.HttpSettings{}},
  1401  			},
  1402  		},
  1403  		probe: sm.Probe{Id: 100, TenantId: 200, Name: "probe name", Region: "REGION"},
  1404  	}
  1405  
  1406  	ts, streams, tenant, d, err := s.CollectData(context.Background(), time.Unix(3141, 0))
  1407  	require.ErrorIs(t, err, errTooManyLabels)
  1408  	require.NotErrorIs(t, err, errCheckFailed)
  1409  	require.Nil(t, ts)
  1410  	require.Nil(t, streams)
  1411  	require.Zero(t, tenant)
  1412  	require.Zero(t, d)
  1413  }
  1414  
  1415  func TestAppendDtoToTimeseries(t *testing.T) {
  1416  	makeUint64Ptr := func(n uint64) *uint64 {
  1417  		return &n
  1418  	}
  1419  
  1420  	makeFloat64Ptr := func(n float64) *float64 {
  1421  		return &n
  1422  	}
  1423  
  1424  	testTime := time.Now()
  1425  	testTimestamp := testTime.UnixNano() / 1e6
  1426  	testValue := 42.0
  1427  	testcases := map[string]struct {
  1428  		t            time.Time
  1429  		mName        string
  1430  		sharedLabels []prompb.Label
  1431  		mType        dto.MetricType
  1432  		metric       *dto.Metric
  1433  		expected     []prompb.TimeSeries
  1434  	}{
  1435  		"counter": {
  1436  			t:     testTime,
  1437  			mName: "test",
  1438  			sharedLabels: []prompb.Label{
  1439  				{Name: "label 1", Value: "value 1"},
  1440  			},
  1441  			mType: dto.MetricType_COUNTER,
  1442  			metric: &dto.Metric{
  1443  				Counter: &dto.Counter{
  1444  					Value: &testValue,
  1445  				},
  1446  			},
  1447  			expected: []prompb.TimeSeries{
  1448  				{
  1449  					Labels: []prompb.Label{
  1450  						{Name: "__name__", Value: "test"},
  1451  						{Name: "label 1", Value: "value 1"},
  1452  					},
  1453  					Samples: []prompb.Sample{
  1454  						{Timestamp: testTimestamp, Value: testValue},
  1455  					},
  1456  				},
  1457  			},
  1458  		},
  1459  		"gauge": {
  1460  			t:     testTime,
  1461  			mName: "test",
  1462  			sharedLabels: []prompb.Label{
  1463  				{Name: "label 1", Value: "value 1"},
  1464  			},
  1465  			mType: dto.MetricType_GAUGE,
  1466  			metric: &dto.Metric{
  1467  				Gauge: &dto.Gauge{
  1468  					Value: &testValue,
  1469  				},
  1470  			},
  1471  			expected: []prompb.TimeSeries{
  1472  				{
  1473  					Labels: []prompb.Label{
  1474  						{Name: "__name__", Value: "test"},
  1475  						{Name: "label 1", Value: "value 1"},
  1476  					},
  1477  					Samples: []prompb.Sample{
  1478  						{Timestamp: testTimestamp, Value: testValue},
  1479  					},
  1480  				},
  1481  			},
  1482  		},
  1483  		"untyped": {
  1484  			t:     testTime,
  1485  			mName: "test",
  1486  			sharedLabels: []prompb.Label{
  1487  				{Name: "label 1", Value: "value 1"},
  1488  			},
  1489  			mType: dto.MetricType_UNTYPED,
  1490  			metric: &dto.Metric{
  1491  				Untyped: &dto.Untyped{
  1492  					Value: &testValue,
  1493  				},
  1494  			},
  1495  			expected: []prompb.TimeSeries{
  1496  				{
  1497  					Labels: []prompb.Label{
  1498  						{Name: "__name__", Value: "test"},
  1499  						{Name: "label 1", Value: "value 1"},
  1500  					},
  1501  					Samples: []prompb.Sample{
  1502  						{Timestamp: testTimestamp, Value: testValue},
  1503  					},
  1504  				},
  1505  			},
  1506  		},
  1507  		"summary": {
  1508  			t:     testTime,
  1509  			mName: "test",
  1510  			sharedLabels: []prompb.Label{
  1511  				{Name: "label 1", Value: "value 1"},
  1512  			},
  1513  			mType: dto.MetricType_SUMMARY,
  1514  			metric: &dto.Metric{
  1515  				Summary: &dto.Summary{
  1516  					SampleCount: makeUint64Ptr(7),
  1517  					SampleSum:   makeFloat64Ptr(0.25),
  1518  				},
  1519  			},
  1520  			expected: []prompb.TimeSeries{
  1521  				{
  1522  					Labels: []prompb.Label{
  1523  						{Name: "__name__", Value: "test_sum"},
  1524  						{Name: "label 1", Value: "value 1"},
  1525  					},
  1526  					Samples: []prompb.Sample{
  1527  						{Timestamp: testTimestamp, Value: 0.25},
  1528  					},
  1529  				},
  1530  				{
  1531  					Labels: []prompb.Label{
  1532  						{Name: "__name__", Value: "test_count"},
  1533  						{Name: "label 1", Value: "value 1"},
  1534  					},
  1535  					Samples: []prompb.Sample{
  1536  						{Timestamp: testTimestamp, Value: 7},
  1537  					},
  1538  				},
  1539  			},
  1540  		},
  1541  		"histogram": {
  1542  			t:     testTime,
  1543  			mName: "test",
  1544  			sharedLabels: []prompb.Label{
  1545  				{Name: "label 1", Value: "value 1"},
  1546  			},
  1547  			mType: dto.MetricType_HISTOGRAM,
  1548  			metric: &dto.Metric{
  1549  				Histogram: &dto.Histogram{
  1550  					SampleCount: makeUint64Ptr(17),
  1551  					SampleSum:   makeFloat64Ptr(120),
  1552  					Bucket: []*dto.Bucket{
  1553  						{
  1554  							CumulativeCount: makeUint64Ptr(1),
  1555  							UpperBound:      makeFloat64Ptr(0.1),
  1556  						},
  1557  						{
  1558  							CumulativeCount: makeUint64Ptr(5),
  1559  							UpperBound:      makeFloat64Ptr(1.0),
  1560  						},
  1561  						{
  1562  							CumulativeCount: makeUint64Ptr(11),
  1563  							UpperBound:      makeFloat64Ptr(10.0),
  1564  						},
  1565  					},
  1566  				},
  1567  			},
  1568  			expected: []prompb.TimeSeries{
  1569  				{
  1570  					Labels: []prompb.Label{
  1571  						{Name: "__name__", Value: "test_sum"},
  1572  						{Name: "label 1", Value: "value 1"},
  1573  					},
  1574  					Samples: []prompb.Sample{
  1575  						{Timestamp: testTimestamp, Value: 120},
  1576  					},
  1577  				},
  1578  				{
  1579  					Labels: []prompb.Label{
  1580  						{Name: "__name__", Value: "test_count"},
  1581  						{Name: "label 1", Value: "value 1"},
  1582  					},
  1583  					Samples: []prompb.Sample{
  1584  						{Timestamp: testTimestamp, Value: 17},
  1585  					},
  1586  				},
  1587  				{
  1588  					Labels: []prompb.Label{
  1589  						{Name: "__name__", Value: "test_bucket"},
  1590  						{Name: "label 1", Value: "value 1"},
  1591  						{Name: "le", Value: "0.1"},
  1592  					},
  1593  					Samples: []prompb.Sample{
  1594  						{Timestamp: testTimestamp, Value: 1},
  1595  					},
  1596  				},
  1597  				{
  1598  					Labels: []prompb.Label{
  1599  						{Name: "__name__", Value: "test_bucket"},
  1600  						{Name: "label 1", Value: "value 1"},
  1601  						{Name: "le", Value: "1"},
  1602  					},
  1603  					Samples: []prompb.Sample{
  1604  						{Timestamp: testTimestamp, Value: 5},
  1605  					},
  1606  				},
  1607  				{
  1608  					Labels: []prompb.Label{
  1609  						{Name: "__name__", Value: "test_bucket"},
  1610  						{Name: "label 1", Value: "value 1"},
  1611  						{Name: "le", Value: "10"},
  1612  					},
  1613  					Samples: []prompb.Sample{
  1614  						{Timestamp: testTimestamp, Value: 11},
  1615  					},
  1616  				},
  1617  				{
  1618  					Labels: []prompb.Label{
  1619  						{Name: "__name__", Value: "test_bucket"},
  1620  						{Name: "label 1", Value: "value 1"},
  1621  						{Name: "le", Value: "+Inf"},
  1622  					},
  1623  					Samples: []prompb.Sample{
  1624  						{Timestamp: testTimestamp, Value: 17},
  1625  					},
  1626  				},
  1627  			},
  1628  		},
  1629  	}
  1630  
  1631  	for name, tc := range testcases {
  1632  		t.Run(name, func(t *testing.T) {
  1633  			actual := appendDtoToTimeseries(
  1634  				nil,
  1635  				tc.t,
  1636  				tc.mName,
  1637  				tc.sharedLabels,
  1638  				tc.mType,
  1639  				tc.metric,
  1640  			)
  1641  
  1642  			require.Equal(t, tc.expected, actual)
  1643  		})
  1644  	}
  1645  }
  1646  
  1647  type testProber struct{}
  1648  
  1649  func (p testProber) Name() string {
  1650  	return "test prober"
  1651  }
  1652  
  1653  func (p testProber) Probe(ctx context.Context, target string, registry *prometheus.Registry, logger logger.Logger, _ string) (bool, float64) {
  1654  	counter := prometheus.NewCounter(prometheus.CounterOpts{
  1655  		Name: "test_counter",
  1656  	})
  1657  	registry.MustRegister(counter)
  1658  	counter.Inc()
  1659  
  1660  	return true, 1
  1661  }
  1662  
  1663  type testLabelsLimiter struct {
  1664  	maxMetricLabels int
  1665  	maxLogLabels    int
  1666  }
  1667  
  1668  func (l testLabelsLimiter) MetricLabels(ctx context.Context, tenantID model.GlobalID) (int, error) {
  1669  	return l.maxMetricLabels, nil
  1670  }
  1671  
  1672  func (l testLabelsLimiter) LogLabels(ctx context.Context, tenantID model.GlobalID) (int, error) {
  1673  	return l.maxLogLabels, nil
  1674  }
  1675  
  1676  type testLabellingMode struct {
  1677  	mode sm.LabelMode
  1678  	err  error
  1679  }
  1680  
  1681  func (l testLabellingMode) ForTenant(ctx context.Context, tenantID model.GlobalID) (sm.LabelMode, error) {
  1682  	return l.mode, l.err
  1683  }
  1684  
  1685  type testCalTenants struct {
  1686  	costAttributionLabels []string
  1687  }
  1688  
  1689  func (t testCalTenants) CostAttributionLabels(_ context.Context, tenantID model.GlobalID) ([]string, error) {
  1690  	return t.costAttributionLabels, nil
  1691  }
  1692  
  1693  //nolint:gocyclo
  1694  func TestScraperCollectData(t *testing.T) {
  1695  	const (
  1696  		checkName     = "check name"
  1697  		checkTarget   = "target name"
  1698  		frequency     = 2000
  1699  		job           = "job name"
  1700  		modifiedTs    = 42
  1701  		probeLatitude = -1
  1702  		probeLongitde = -2
  1703  		probeName     = "probe name"
  1704  		region        = "REGION"
  1705  		sampleTsMs    = int64(3141000)
  1706  	)
  1707  
  1708  	var (
  1709  		baseExpectedMetricLabels = map[string]string{
  1710  			configVersionLabelName: strconv.Itoa(modifiedTs * 1_000_000_000),
  1711  			"instance":             checkTarget,
  1712  			"job":                  job,
  1713  			"probe":                probeName,
  1714  			// "source":         CheckInfoSource,
  1715  		}
  1716  		baseExpectedInfoLabels = map[string]string{
  1717  			"check_name": checkName,
  1718  			"frequency":  strconv.Itoa(frequency),
  1719  			"geohash":    geohash.Encode(probeLatitude, probeLongitde),
  1720  			"region":     region,
  1721  		}
  1722  		baseExpectedLogLabels = map[string]string{
  1723  			"check_name":           checkName,
  1724  			"instance":             checkTarget,
  1725  			"job":                  job,
  1726  			"probe":                probeName,
  1727  			ProbeSuccessMetricName: "1",
  1728  			"region":               region,
  1729  			"source":               CheckInfoSource,
  1730  		}
  1731  
  1732  		// Tenants that have started the label migration (DUAL_WRITE/UNPREFIXED)
  1733  		// receive the agent's region label as sm_region instead of region.
  1734  		baseExpectedInfoLabelsSMRegion = map[string]string{
  1735  			"check_name": checkName,
  1736  			"frequency":  strconv.Itoa(frequency),
  1737  			"geohash":    geohash.Encode(probeLatitude, probeLongitde),
  1738  			"sm_region":  region,
  1739  		}
  1740  		baseExpectedLogLabelsSMRegion = map[string]string{
  1741  			"check_name":           checkName,
  1742  			"instance":             checkTarget,
  1743  			"job":                  job,
  1744  			"probe":                probeName,
  1745  			ProbeSuccessMetricName: "1",
  1746  			"sm_region":            region,
  1747  			"source":               CheckInfoSource,
  1748  		}
  1749  	)
  1750  
  1751  	generateLabels := func(offset, count int, valuePrefix string) []sm.Label {
  1752  		var labels []sm.Label
  1753  
  1754  		for i := range count {
  1755  			n := strconv.Itoa(offset + i)
  1756  			labels = append(labels, sm.Label{
  1757  				Name:  "l" + n,
  1758  				Value: valuePrefix + n,
  1759  			})
  1760  		}
  1761  
  1762  		return labels
  1763  	}
  1764  
  1765  	generateLabelSet := func(offset, count int, valuePrefix string) map[string]string {
  1766  		labels := make(map[string]string)
  1767  
  1768  		for i := range count {
  1769  			n := strconv.Itoa(offset + i)
  1770  			labels["label_l"+n] = valuePrefix + n
  1771  		}
  1772  
  1773  		return labels
  1774  	}
  1775  
  1776  	// generateUnprefixedLabelSet produces {"l0":"c0","l1":"c1",...} — used to describe
  1777  	// expected user labels on execution metrics in DUAL_WRITE and UNPREFIXED modes.
  1778  	generateUnprefixedLabelSet := func(offset, count int, valuePrefix string) map[string]string {
  1779  		labels := make(map[string]string)
  1780  
  1781  		for i := range count {
  1782  			n := strconv.Itoa(offset + i)
  1783  			labels["l"+n] = valuePrefix + n
  1784  		}
  1785  
  1786  		return labels
  1787  	}
  1788  
  1789  	type testcase struct {
  1790  		labelMode            sm.LabelMode // zero value = PREFIXED
  1791  		labelModeErr         error        // simulates a tenant label-mode lookup failure
  1792  		maxMetricLabels      int
  1793  		maxLogLabels         int
  1794  		checkLabels          []sm.Label
  1795  		probeLabels          []sm.Label
  1796  		expectedMetricLabels map[string]string
  1797  		expectedInfoLabels   map[string]string
  1798  		expectedLogLabels    map[string]string
  1799  		expectedLogEntries   map[string]string
  1800  	}
  1801  
  1802  	const (
  1803  		defMaxMetricLabels = 22
  1804  		defMaxLogLabels    = 15
  1805  	)
  1806  
  1807  	testcases := map[string]testcase{
  1808  		"trivial": {
  1809  			maxMetricLabels:      defMaxMetricLabels,
  1810  			maxLogLabels:         defMaxLogLabels,
  1811  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1812  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels),
  1813  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels),
  1814  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels),
  1815  		},
  1816  		"probe labels": {
  1817  			maxMetricLabels:      defMaxMetricLabels,
  1818  			maxLogLabels:         defMaxLogLabels,
  1819  			probeLabels:          generateLabels(1, 3, "p"),
  1820  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1821  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(1, 3, "p")),
  1822  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(1, 3, "p")),
  1823  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(1, 3, "p")),
  1824  		},
  1825  		"check labels": {
  1826  			maxMetricLabels:      defMaxMetricLabels,
  1827  			maxLogLabels:         defMaxLogLabels,
  1828  			checkLabels:          generateLabels(1, 3, "c"),
  1829  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1830  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(1, 3, "c")),
  1831  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(1, 3, "c")),
  1832  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(1, 3, "c")),
  1833  		},
  1834  		"check and probe labels": {
  1835  			maxMetricLabels:      defMaxMetricLabels,
  1836  			maxLogLabels:         defMaxLogLabels,
  1837  			checkLabels:          generateLabels(1, 2, "c"),
  1838  			probeLabels:          generateLabels(3, 1, "p"),
  1839  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1840  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1841  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1842  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1843  		},
  1844  		"check and probe labels overlapping": {
  1845  			maxMetricLabels:      defMaxMetricLabels,
  1846  			maxLogLabels:         defMaxLogLabels,
  1847  			checkLabels:          generateLabels(1, 2, "c"),
  1848  			probeLabels:          generateLabels(2, 2, "p"),
  1849  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1850  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1851  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1852  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(3, 1, "p"), generateLabelSet(1, 2, "c")),
  1853  		},
  1854  		"max labels truncate": {
  1855  			maxMetricLabels:      defMaxMetricLabels,
  1856  			maxLogLabels:         defMaxLogLabels,
  1857  			checkLabels:          generateLabels(0, 10, "c"),
  1858  			probeLabels:          generateLabels(10, 3, "p"),
  1859  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1860  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(0, 10, "c"), generateLabelSet(10, 3, "p")),
  1861  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(0, 5, "c"), generateLabelSet(10, 3, "p")),
  1862  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(0, 10, "c"), generateLabelSet(10, 3, "p")),
  1863  		},
  1864  		"max labels override": {
  1865  			maxMetricLabels:      23,
  1866  			maxLogLabels:         23,
  1867  			checkLabels:          generateLabels(0, 10, "c"),
  1868  			probeLabels:          generateLabels(10, 4, "p"),
  1869  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels),
  1870  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabels, generateLabelSet(0, 10, "c"), generateLabelSet(10, 4, "p")),
  1871  			expectedLogLabels:    mergeMaps(baseExpectedLogLabels, generateLabelSet(0, 10, "c"), generateLabelSet(10, 4, "p")),
  1872  			expectedLogEntries:   mergeMaps(baseExpectedLogLabels, generateLabelSet(0, 10, "c"), generateLabelSet(10, 4, "p")),
  1873  		},
  1874  
  1875  		// ── DUAL_WRITE and UNPREFIXED mode tests ──────────────────────────────────
  1876  		//
  1877  		// Key differences:
  1878  		//   expectedMetricLabels includes un-prefixed user labels (execution metrics
  1879  		//     gain user labels via customMetricLabels in DUAL_WRITE/UNPREFIXED).
  1880  		//   expectedInfoLabels includes both forms in DUAL_WRITE (prefixed first in the
  1881  		//     slice produced by buildUserLabels, then un-prefixed) and un-prefixed only
  1882  		//     in UNPREFIXED.
  1883  		//   expectedLogLabels follows DUAL_WRITE ordering: all prefixed forms before all
  1884  		//     un-prefixed forms in the stream labels; overflow goes to StructuredMetadata.
  1885  
  1886  		"dual write basic": {
  1887  			// 3 check labels + 1 probe label; no stream-label overflow.
  1888  			// Verifies: execution metrics carry un-prefixed user labels;
  1889  			//           sm_check_info carries both forms;
  1890  			//           log stream carries both forms in correct ordering.
  1891  			labelMode:       sm.LabelMode_LABEL_MODE_DUAL_WRITE,
  1892  			maxMetricLabels: defMaxMetricLabels,
  1893  			maxLogLabels:    defMaxLogLabels,
  1894  			checkLabels:     generateLabels(0, 3, "c"),
  1895  			probeLabels:     generateLabels(10, 1, "p"),
  1896  			// Execution metrics: base + un-prefixed user labels.
  1897  			expectedMetricLabels: mergeMaps(
  1898  				baseExpectedMetricLabels,
  1899  				generateUnprefixedLabelSet(10, 1, "p"),
  1900  				generateUnprefixedLabelSet(0, 3, "c"),
  1901  			),
  1902  			// sm_check_info: system sharedLabels (probe, config_version, instance, job)
  1903  			// + check-info system labels + both forms of user labels from ConstLabels.
  1904  			expectedInfoLabels: mergeMaps(
  1905  				baseExpectedMetricLabels,
  1906  				baseExpectedInfoLabelsSMRegion,
  1907  				generateLabelSet(10, 1, "p"),
  1908  				generateUnprefixedLabelSet(10, 1, "p"),
  1909  				generateLabelSet(0, 3, "c"),
  1910  				generateUnprefixedLabelSet(0, 3, "c"),
  1911  			),
  1912  			// Log stream: DUAL_WRITE ordering — all prefixed first, then un-prefixed.
  1913  			// 4 user labels × 2 = 8 entries + 6 system labels = 14; probe_success
  1914  			// is appended last, totalling 15 = defMaxLogLabels. No overflow.
  1915  			expectedLogLabels: mergeMaps(
  1916  				baseExpectedLogLabelsSMRegion,
  1917  				generateLabelSet(10, 1, "p"),
  1918  				generateLabelSet(0, 3, "c"),
  1919  				generateUnprefixedLabelSet(10, 1, "p"),
  1920  				generateUnprefixedLabelSet(0, 3, "c"),
  1921  			),
  1922  			expectedLogEntries: mergeMaps(
  1923  				baseExpectedLogLabelsSMRegion,
  1924  				generateLabelSet(10, 1, "p"),
  1925  				generateLabelSet(0, 3, "c"),
  1926  				generateUnprefixedLabelSet(10, 1, "p"),
  1927  				generateUnprefixedLabelSet(0, 3, "c"),
  1928  			),
  1929  		},
  1930  
  1931  		"unprefixed basic": {
  1932  			// 3 check labels + 1 probe label in UNPREFIXED mode.
  1933  			// Verifies: execution metrics carry un-prefixed user labels;
  1934  			//           sm_check_info and log stream carry only un-prefixed forms.
  1935  			labelMode:       sm.LabelMode_LABEL_MODE_UNPREFIXED,
  1936  			maxMetricLabels: defMaxMetricLabels,
  1937  			maxLogLabels:    defMaxLogLabels,
  1938  			checkLabels:     generateLabels(0, 3, "c"),
  1939  			probeLabels:     generateLabels(10, 1, "p"),
  1940  			expectedMetricLabels: mergeMaps(
  1941  				baseExpectedMetricLabels,
  1942  				generateUnprefixedLabelSet(10, 1, "p"),
  1943  				generateUnprefixedLabelSet(0, 3, "c"),
  1944  			),
  1945  			expectedInfoLabels: mergeMaps(
  1946  				baseExpectedMetricLabels,
  1947  				baseExpectedInfoLabelsSMRegion,
  1948  				generateUnprefixedLabelSet(10, 1, "p"),
  1949  				generateUnprefixedLabelSet(0, 3, "c"),
  1950  			),
  1951  			expectedLogLabels: mergeMaps(
  1952  				baseExpectedLogLabelsSMRegion,
  1953  				generateUnprefixedLabelSet(10, 1, "p"),
  1954  				generateUnprefixedLabelSet(0, 3, "c"),
  1955  			),
  1956  			expectedLogEntries: mergeMaps(
  1957  				baseExpectedLogLabelsSMRegion,
  1958  				generateUnprefixedLabelSet(10, 1, "p"),
  1959  				generateUnprefixedLabelSet(0, 3, "c"),
  1960  			),
  1961  		},
  1962  
  1963  		"dual write log overflow": {
  1964  			// 10 check labels + 3 probe labels in DUAL_WRITE mode causes log stream
  1965  			// overflow. DUAL_WRITE ordering means all prefixed forms come first in the
  1966  			// stream labels, so they are preserved when the label count exceeds the cap;
  1967  			// the un-prefixed forms spill into StructuredMetadata instead.
  1968  			//
  1969  			// Label budget: 6 system + 26 user (13×2) = 32 total; cap at
  1970  			// defMaxLogLabels-1=14 stream slots before probe_success.
  1971  			// Stream labels: system labels (6) + first 8 prefixed user labels.
  1972  			// Overflow into StructuredMetadata: remaining prefixed (5) + all 13
  1973  			// un-prefixed user labels.
  1974  			labelMode:       sm.LabelMode_LABEL_MODE_DUAL_WRITE,
  1975  			maxMetricLabels: defMaxMetricLabels,
  1976  			maxLogLabels:    defMaxLogLabels,
  1977  			checkLabels:     generateLabels(0, 10, "c"),
  1978  			probeLabels:     generateLabels(10, 3, "p"),
  1979  			// Execution metrics: base + all un-prefixed user labels.
  1980  			expectedMetricLabels: mergeMaps(
  1981  				baseExpectedMetricLabels,
  1982  				generateUnprefixedLabelSet(10, 3, "p"),
  1983  				generateUnprefixedLabelSet(0, 10, "c"),
  1984  			),
  1985  			// sm_check_info: system sharedLabels + both forms of user labels from ConstLabels.
  1986  			expectedInfoLabels: mergeMaps(
  1987  				baseExpectedMetricLabels,
  1988  				baseExpectedInfoLabelsSMRegion,
  1989  				generateLabelSet(10, 3, "p"),
  1990  				generateUnprefixedLabelSet(10, 3, "p"),
  1991  				generateLabelSet(0, 10, "c"),
  1992  				generateUnprefixedLabelSet(0, 10, "c"),
  1993  			),
  1994  			// Stream labels: system(6) + 8 prefixed user labels (prefixed-first
  1995  			// ordering means label_l10,label_l11,label_l12 + label_l0..label_l4
  1996  			// fill the 8 available slots before the cap).
  1997  			expectedLogLabels: mergeMaps(
  1998  				baseExpectedLogLabelsSMRegion,
  1999  				generateLabelSet(10, 3, "p"), // label_l10, label_l11, label_l12
  2000  				generateLabelSet(0, 5, "c"),  // label_l0 .. label_l4
  2001  			),
  2002  			// Log entry body includes all user labels in both forms (log entry is not
  2003  			// truncated, only stream labels are capped). The entries in
  2004  			// expectedLogEntries \ expectedLogLabels become StructuredMetadata overflow
  2005  			// and are validated by assertStructuredMetadata.
  2006  			expectedLogEntries: mergeMaps(
  2007  				baseExpectedLogLabelsSMRegion,
  2008  				generateLabelSet(10, 3, "p"),
  2009  				generateLabelSet(0, 10, "c"),
  2010  				generateUnprefixedLabelSet(10, 3, "p"),
  2011  				generateUnprefixedLabelSet(0, 10, "c"),
  2012  			),
  2013  		},
  2014  
  2015  		// In UNPREFIXED mode a user label named "job" collides with the reserved
  2016  		// system "job" label. buildUserLabels drops it (sm.IsSystemLabel), so the
  2017  		// system value is retained on every surface — execution metrics,
  2018  		// sm_check_info, and the log stream — rather than being overwritten. The
  2019  		// non-reserved "env" label is emitted normally.
  2020  		"unprefixed reserved label dropped": {
  2021  			labelMode:       sm.LabelMode_LABEL_MODE_UNPREFIXED,
  2022  			maxMetricLabels: defMaxMetricLabels,
  2023  			maxLogLabels:    defMaxLogLabels,
  2024  			checkLabels: []sm.Label{
  2025  				{Name: "job", Value: "user-job"},
  2026  				{Name: "env", Value: "prod"},
  2027  			},
  2028  			// "job" keeps its system value (from base); only "env" is added.
  2029  			expectedMetricLabels: mergeMaps(
  2030  				baseExpectedMetricLabels,
  2031  				map[string]string{"env": "prod"},
  2032  			),
  2033  			expectedInfoLabels: mergeMaps(
  2034  				baseExpectedMetricLabels,
  2035  				baseExpectedInfoLabelsSMRegion,
  2036  				map[string]string{"env": "prod"},
  2037  			),
  2038  			expectedLogLabels: mergeMaps(
  2039  				baseExpectedLogLabelsSMRegion,
  2040  				map[string]string{"env": "prod"},
  2041  			),
  2042  			expectedLogEntries: mergeMaps(
  2043  				baseExpectedLogLabelsSMRegion,
  2044  				map[string]string{"env": "prod"},
  2045  			),
  2046  		},
  2047  
  2048  		// Renaming the agent's own region label to sm_region frees "region" for
  2049  		// tenant use: a user label named region must reach every surface intact,
  2050  		// alongside the agent's sm_region.
  2051  		"unprefixed user region label freed": {
  2052  			labelMode:       sm.LabelMode_LABEL_MODE_UNPREFIXED,
  2053  			maxMetricLabels: defMaxMetricLabels,
  2054  			maxLogLabels:    defMaxLogLabels,
  2055  			checkLabels: []sm.Label{
  2056  				{Name: "region", Value: "user-region"},
  2057  			},
  2058  			expectedMetricLabels: mergeMaps(
  2059  				baseExpectedMetricLabels,
  2060  				map[string]string{"region": "user-region"},
  2061  			),
  2062  			expectedInfoLabels: mergeMaps(
  2063  				baseExpectedMetricLabels,
  2064  				baseExpectedInfoLabelsSMRegion,
  2065  				map[string]string{"region": "user-region"},
  2066  			),
  2067  			expectedLogLabels: mergeMaps(
  2068  				baseExpectedLogLabelsSMRegion,
  2069  				map[string]string{"region": "user-region"},
  2070  			),
  2071  			expectedLogEntries: mergeMaps(
  2072  				baseExpectedLogLabelsSMRegion,
  2073  				map[string]string{"region": "user-region"},
  2074  			),
  2075  		},
  2076  
  2077  		// Same as above but in DUAL_WRITE: the riskiest mode, where the user's
  2078  		// region (as both region and label_region) coexists with the agent's
  2079  		// sm_region — three distinct keys, no duplicates, on every surface.
  2080  		"dual write user region label freed": {
  2081  			labelMode:       sm.LabelMode_LABEL_MODE_DUAL_WRITE,
  2082  			maxMetricLabels: defMaxMetricLabels,
  2083  			maxLogLabels:    defMaxLogLabels,
  2084  			checkLabels: []sm.Label{
  2085  				{Name: "region", Value: "user-region"},
  2086  			},
  2087  			// Execution metrics carry only the un-prefixed user form.
  2088  			expectedMetricLabels: mergeMaps(
  2089  				baseExpectedMetricLabels,
  2090  				map[string]string{"region": "user-region"},
  2091  			),
  2092  			expectedInfoLabels: mergeMaps(
  2093  				baseExpectedMetricLabels,
  2094  				baseExpectedInfoLabelsSMRegion,
  2095  				map[string]string{"label_region": "user-region", "region": "user-region"},
  2096  			),
  2097  			expectedLogLabels: mergeMaps(
  2098  				baseExpectedLogLabelsSMRegion,
  2099  				map[string]string{"label_region": "user-region", "region": "user-region"},
  2100  			),
  2101  			expectedLogEntries: mergeMaps(
  2102  				baseExpectedLogLabelsSMRegion,
  2103  				map[string]string{"label_region": "user-region", "region": "user-region"},
  2104  			),
  2105  		},
  2106  
  2107  		// This tests simulates the default label mode falling back to UNPREFIXED, but note
  2108  		// the scraper would actually abort if there was no tenant in cache due to
  2109  		// the label limits being unavailable.
  2110  		"label mode fetch error degrades to unprefixed": {
  2111  			labelMode:            sm.LabelMode_LABEL_MODE_UNPREFIXED,
  2112  			labelModeErr:         errors.New("simulated tenant label-mode fetch failure"),
  2113  			maxMetricLabels:      defMaxMetricLabels,
  2114  			maxLogLabels:         defMaxLogLabels,
  2115  			checkLabels:          generateLabels(1, 3, "c"),
  2116  			expectedMetricLabels: mergeMaps(baseExpectedMetricLabels, generateUnprefixedLabelSet(1, 3, "c")),
  2117  			expectedInfoLabels:   mergeMaps(baseExpectedMetricLabels, baseExpectedInfoLabelsSMRegion, generateUnprefixedLabelSet(1, 3, "c")),
  2118  			expectedLogLabels:    mergeMaps(baseExpectedLogLabelsSMRegion, generateUnprefixedLabelSet(1, 3, "c")),
  2119  			expectedLogEntries:   mergeMaps(baseExpectedLogLabelsSMRegion, generateUnprefixedLabelSet(1, 3, "c")),
  2120  		},
  2121  	}
  2122  
  2123  	getMetricName := func(t *testing.T, ts prompb.TimeSeries) string {
  2124  		for _, l := range ts.GetLabels() {
  2125  			if l.GetName() != prom.MetricNameLabel {
  2126  				continue
  2127  			}
  2128  
  2129  			return l.GetValue()
  2130  		}
  2131  
  2132  		require.Fail(t, "metric name not found")
  2133  
  2134  		return ""
  2135  	}
  2136  
  2137  	validateMetrics := func(t *testing.T, ts prompb.TimeSeries, tc testcase) {
  2138  		require.NotNil(t, ts)
  2139  
  2140  		metricName := getMetricName(t, ts)
  2141  
  2142  		// Ensure no label name appears more than once. Duplicate label keys are
  2143  		// rejected by Mimir and indicate a bug in label assembly (e.g. user labels
  2144  		// reaching a metric via both ConstLabels and sharedLabels).
  2145  		seenLabelNames := make(map[string]bool, len(ts.GetLabels()))
  2146  		for _, l := range ts.GetLabels() {
  2147  			require.Falsef(t, seenLabelNames[l.GetName()],
  2148  				"duplicate label %q in metric %s", l.GetName(), metricName)
  2149  			seenLabelNames[l.GetName()] = true
  2150  		}
  2151  
  2152  		actualLabels := make(map[string]string)
  2153  		actualLabelsCount := 0
  2154  		actualInfoLabels := make(map[string]string)
  2155  		actualInfoLabelsCount := 0
  2156  
  2157  		// Verify that all the expected metric labels are present
  2158  
  2159  		for _, l := range ts.GetLabels() {
  2160  			switch {
  2161  			case l.GetName() == prom.MetricNameLabel:
  2162  				// ignore
  2163  
  2164  			case l.GetName() == labels.BucketLabel:
  2165  				// ignore
  2166  
  2167  			case metricName == CheckInfoMetricName:
  2168  				expectedValue, isExpected := tc.expectedInfoLabels[l.GetName()]
  2169  				require.Truef(t, isExpected, "metric=%s label=%s value=%s", metricName, l.GetName(), l.GetValue())
  2170  				require.Equal(t, expectedValue, l.GetValue())
  2171  				actualInfoLabels[l.GetName()] = l.GetValue()
  2172  				actualInfoLabelsCount++
  2173  
  2174  			default:
  2175  				expectedValue, isExpected := tc.expectedMetricLabels[l.GetName()]
  2176  				require.Truef(t, isExpected, "unexpected label: metric=%s label=%s value=%s", metricName, l.GetName(), l.GetValue())
  2177  				require.Equal(t, expectedValue, l.GetValue())
  2178  				actualLabels[l.GetName()] = l.GetValue()
  2179  				actualLabelsCount++
  2180  			}
  2181  		}
  2182  
  2183  		if metricName == CheckInfoMetricName {
  2184  			require.Equal(t, tc.expectedInfoLabels, actualInfoLabels)
  2185  			require.Equal(t, len(tc.expectedInfoLabels), actualInfoLabelsCount)
  2186  		} else {
  2187  			require.Equal(t, tc.expectedMetricLabels, actualLabels)
  2188  			require.Equal(t, len(tc.expectedMetricLabels), actualLabelsCount)
  2189  		}
  2190  
  2191  		for _, sample := range ts.GetSamples() {
  2192  			// This encodes the assumption that there's a single timestamp included in the
  2193  			// resulting metrics.
  2194  			require.Equal(t, sampleTsMs, sample.Timestamp)
  2195  		}
  2196  	}
  2197  
  2198  	// assertStructuredMetadata verifies that each log entry's StructuredMetadata
  2199  	// contains a valid execution_id UUID plus any overflow labels (those present in
  2200  	// expectedLogEntries but absent from expectedLogLabels — they fit in the log
  2201  	// entry body but were truncated from the stream label set).
  2202  	assertStructuredMetadata := func(t *testing.T, entry logproto.Entry, tc testcase) {
  2203  		t.Helper()
  2204  
  2205  		executionIDFound := false
  2206  		overflowFound := 0
  2207  
  2208  		for _, meta := range entry.StructuredMetadata {
  2209  			if meta.Name == "execution_id" {
  2210  				executionIDFound = true
  2211  				_, err := uuid.Parse(meta.Value)
  2212  				require.NoError(t, err, "execution_id should be a valid UUID")
  2213  			} else {
  2214  				expectedVal, inEntries := tc.expectedLogEntries[meta.Name]
  2215  				_, inLabels := tc.expectedLogLabels[meta.Name]
  2216  				require.Truef(t, inEntries && !inLabels,
  2217  					"unexpected structured metadata entry: %s=%s", meta.Name, meta.Value)
  2218  				require.Equal(t, expectedVal, meta.Value, "overflow label value mismatch: %s", meta.Name)
  2219  
  2220  				overflowFound++
  2221  			}
  2222  		}
  2223  
  2224  		require.True(t, executionIDFound, "execution_id not found in structured metadata")
  2225  
  2226  		expectedOverflow := 0
  2227  
  2228  		for name := range tc.expectedLogEntries {
  2229  			if _, inLabels := tc.expectedLogLabels[name]; !inLabels {
  2230  				expectedOverflow++
  2231  			}
  2232  		}
  2233  
  2234  		require.Equal(t, expectedOverflow, overflowFound, "overflow label count in structured metadata")
  2235  	}
  2236  
  2237  	validateStreams := func(t *testing.T, s Scraper, stream logproto.Stream, tc testcase) {
  2238  		sLabels, err := metricParser.ParseMetric(stream.Labels)
  2239  		require.NoError(t, err)
  2240  
  2241  		// Verify that all the expected log labels are present as labels in the stream labels.
  2242  		found := 0
  2243  
  2244  		sLabels.Range(func(label labels.Label) {
  2245  			expected, ok := tc.expectedLogLabels[label.Name]
  2246  			require.Truef(t, ok, "key=%s value=%s labels=%s", label.Name, label.Value, stream.Labels)
  2247  			require.Equalf(t, expected, label.Value, "key=%s", label.Name)
  2248  
  2249  			found++
  2250  		})
  2251  
  2252  		require.Equal(t, len(tc.expectedLogLabels), found, stream.Labels)
  2253  
  2254  		// Verify that all the expected log labels are present as part of the actual log entry.
  2255  		for _, entry := range stream.Entries {
  2256  			dec := logfmt.NewDecoder(strings.NewReader(entry.Line))
  2257  			for dec.ScanRecord() {
  2258  				labelsFound := 1 // probe_success is NOT included in the log entry
  2259  
  2260  				for dec.ScanKeyval() {
  2261  					key := string(dec.Key())
  2262  					val := string(dec.Value())
  2263  
  2264  					switch key {
  2265  					case "level", "msg", "timeout_seconds", "duration_seconds", "walltime_seconds":
  2266  					case "target":
  2267  						require.Equal(t, s.target, val)
  2268  					case "type":
  2269  						require.Equal(t, s.prober.Name(), val)
  2270  					default:
  2271  						expected, found := tc.expectedLogEntries[key]
  2272  						require.Truef(t, found, "key=%s value=%s", key, val)
  2273  						require.Equalf(t, expected, val, "key=%s", key)
  2274  
  2275  						labelsFound++
  2276  					}
  2277  				}
  2278  
  2279  				require.Equal(t, len(tc.expectedLogEntries), labelsFound)
  2280  			}
  2281  
  2282  			require.NoError(t, dec.Err())
  2283  
  2284  			assertStructuredMetadata(t, entry, tc)
  2285  		}
  2286  	}
  2287  
  2288  	for name, tc := range testcases {
  2289  		t.Run(name, func(t *testing.T) {
  2290  			s := Scraper{
  2291  				checkName: checkName,
  2292  				target:    "test target",
  2293  				logger:    testhelper.Logger(t),
  2294  				prober:    testProber{},
  2295  				labelsLimiter: testLabelsLimiter{
  2296  					maxMetricLabels: tc.maxMetricLabels,
  2297  					maxLogLabels:    tc.maxLogLabels,
  2298  				},
  2299  				labellingMode: testLabellingMode{mode: tc.labelMode, err: tc.labelModeErr},
  2300  				summaries:     make(map[uint64]prometheus.Summary),
  2301  				histograms:    make(map[uint64]prometheus.Histogram),
  2302  				check: model.Check{
  2303  					Check: sm.Check{
  2304  						Id:               1,
  2305  						TenantId:         2,
  2306  						Frequency:        frequency,
  2307  						Timeout:          frequency,
  2308  						Enabled:          true,
  2309  						Target:           checkTarget,
  2310  						Job:              job,
  2311  						BasicMetricsOnly: true,
  2312  						Created:          modifiedTs,
  2313  						Modified:         modifiedTs,
  2314  						Labels:           tc.checkLabels,
  2315  						// [Check.Type] panics if all settings are nil. To work around that, we add an empty, non nil
  2316  						// HTTP settings section.
  2317  						Settings: sm.CheckSettings{
  2318  							Http: &sm.HttpSettings{},
  2319  						},
  2320  					},
  2321  				},
  2322  				probe: sm.Probe{
  2323  					Id:        100,
  2324  					TenantId:  200,
  2325  					Name:      probeName,
  2326  					Latitude:  probeLatitude,
  2327  					Longitude: probeLongitde,
  2328  					Region:    region,
  2329  					Labels:    tc.probeLabels,
  2330  				},
  2331  			}
  2332  
  2333  			data, duration, err := s.collectData(context.Background(), time.Unix(sampleTsMs/1000, 0))
  2334  			require.NoError(t, err)
  2335  			require.NotNil(t, data)
  2336  			require.NotZero(t, duration)
  2337  
  2338  			for _, ts := range data.Metrics() {
  2339  				validateMetrics(t, ts, tc)
  2340  			}
  2341  
  2342  			for _, stream := range data.Streams() {
  2343  				validateStreams(t, s, stream, tc)
  2344  			}
  2345  		})
  2346  	}
  2347  }
  2348  
  2349  func findPromPbLabel(t *testing.T, labels []prompb.Label, name string) string {
  2350  	idx := slices.IndexFunc(labels, func(v prompb.Label) bool { return v.GetName() == name })
  2351  	require.GreaterOrEqualf(t, idx, int(0), "label %q must be present", name)
  2352  
  2353  	return labels[idx].GetValue()
  2354  }
  2355  
  2356  // histogramProber emits a single histogram (test_duration_seconds) with several
  2357  // buckets, so the gathered metric families contain multiple *_bucket series that
  2358  // differ only by their intrinsic `le` label. This is the shape that makes a
  2359  // reserved-name collision destructive: unlike sm_check_info's single-valued
  2360  // identity labels, `le` is per-series.
  2361  type histogramProber struct{}
  2362  
  2363  func (histogramProber) Name() string { return "histogram prober" }
  2364  
  2365  func (histogramProber) Probe(_ context.Context, _ string, registry *prometheus.Registry, _ logger.Logger, _ string) (bool, float64) {
  2366  	h := prometheus.NewHistogram(prometheus.HistogramOpts{
  2367  		Name:    "test_duration_seconds",
  2368  		Buckets: []float64{0.1, 0.5, 1},
  2369  	})
  2370  	registry.MustRegister(h)
  2371  
  2372  	// Observations spread across buckets so every bucket series is distinct.
  2373  	h.Observe(0.05)
  2374  	h.Observe(0.3)
  2375  	h.Observe(0.8)
  2376  
  2377  	return true, 1
  2378  }
  2379  
  2380  // TestScraperCollectDataReservedLabelCollision pins the agent's behaviour when
  2381  // a tenant carries a user label whose name collides with a *per-series
  2382  // intrinsic* reserved label (`le`) while in UNPREFIXED mode.
  2383  //
  2384  // Per checks_extra.go, the API is supposed to make this unreachable: reserved
  2385  // names are rejected at check create/update and at the LabelMode transition.
  2386  // This test is the agent-side backstop for the case where such a label reaches
  2387  // the scraper anyway. It asserts the safe contract: the histogram's real
  2388  // bucket boundaries survive as distinct series ({0.1, 0.5, 1, +Inf}) and are
  2389  // NOT collapsed onto the user's value.
  2390  //
  2391  // TODO(mem): Remove this note. This test FAILS against the current
  2392  // implementation. customMetricLabels seeds the user `le` into
  2393  // executionMetricLabels, appendDtoToTimeseries appends the real bucket `le`
  2394  // last, and makeTimeseries de-dupes keeping the first occurrence, so every
  2395  // bucket collapses to le=user-value. It will pass once per-series
  2396  // reserved-name filtering is added to customMetricLabels. The same collapse
  2397  // also corrupts the agent's own probe_all_duration_seconds histogram, so this
  2398  // is not limited to custom prober metrics.
  2399  func TestScraperCollectDataReservedLabelCollision(t *testing.T) {
  2400  	s := Scraper{
  2401  		checkName:     "check name",
  2402  		target:        "test target",
  2403  		logger:        testhelper.Logger(t),
  2404  		prober:        histogramProber{},
  2405  		labelsLimiter: testLabelsLimiter{maxMetricLabels: 22, maxLogLabels: 15},
  2406  		labellingMode: testLabellingMode{mode: sm.LabelMode_LABEL_MODE_UNPREFIXED},
  2407  		summaries:     make(map[uint64]prometheus.Summary),
  2408  		histograms:    make(map[uint64]prometheus.Histogram),
  2409  		check: model.Check{
  2410  			Check: sm.Check{
  2411  				Id:               1,
  2412  				TenantId:         2,
  2413  				Frequency:        2000,
  2414  				Timeout:          2000,
  2415  				Enabled:          true,
  2416  				Target:           "target name",
  2417  				Job:              "job name",
  2418  				BasicMetricsOnly: false,
  2419  				Created:          42,
  2420  				Modified:         42,
  2421  				// User label collides with the reserved, per-series `le`.
  2422  				Labels:   []sm.Label{{Name: "le", Value: "user-value"}},
  2423  				Settings: sm.CheckSettings{Http: &sm.HttpSettings{}},
  2424  			},
  2425  		},
  2426  		probe: sm.Probe{
  2427  			Id:       100,
  2428  			TenantId: 200,
  2429  			Name:     "probe name",
  2430  			Region:   "REGION",
  2431  		},
  2432  	}
  2433  
  2434  	data, _, err := s.collectData(context.Background(), time.Unix(3141, 0))
  2435  	require.NoError(t, err)
  2436  	require.NotNil(t, data)
  2437  
  2438  	// Collect the `le` value from every test_duration_seconds_bucket
  2439  	// series. Scoped to this histogram so the count is deterministic; note
  2440  	// that the agent's own probe_all_duration_seconds_bucket series
  2441  	// collapse identically.
  2442  	bucketLEs := make(map[string]int)
  2443  	bucketSeries := 0
  2444  
  2445  	for _, ts := range data.Metrics() {
  2446  		name := findPromPbLabel(t, ts.GetLabels(), prom.MetricNameLabel)
  2447  		if name != "test_duration_seconds_bucket" {
  2448  			continue
  2449  		}
  2450  
  2451  		le := findPromPbLabel(t, ts.GetLabels(), labels.BucketLabel)
  2452  
  2453  		bucketSeries++
  2454  		bucketLEs[le]++
  2455  	}
  2456  
  2457  	// A 3-boundary histogram yields 4 bucket series: 0.1, 0.5, 1, +Inf.
  2458  	require.Equal(t, 4, bucketSeries, "expected one series per bucket boundary")
  2459  
  2460  	// The intended contract: real bucket boundaries survive, distinct and
  2461  	// un-clobbered by the user's `le=user-value`.
  2462  	require.Equal(t,
  2463  		map[string]int{"0.1": 1, "0.5": 1, "1": 1, "+Inf": 1},
  2464  		bucketLEs,
  2465  		"histogram bucket `le` labels were overwritten/collapsed by the user label")
  2466  }
  2467  
  2468  // phaseProber emits probe_http_duration_seconds as a gauge with a `phase`
  2469  // label, mirroring the real HTTP prober's per-phase timing breakdown. `phase`
  2470  // is a reserved, per-series label (checks_extra.go) but, unlike `le` above, it
  2471  // is a name a tenant might plausibly define themselves, which makes this the
  2472  // more realistic collision vehicle.
  2473  type phaseProber struct{}
  2474  
  2475  func (phaseProber) Name() string { return "phase prober" }
  2476  
  2477  func (phaseProber) Probe(_ context.Context, _ string, registry *prometheus.Registry, _ logger.Logger, _ string) (bool, float64) {
  2478  	g := prometheus.NewGaugeVec(prometheus.GaugeOpts{
  2479  		Name: "probe_http_duration_seconds",
  2480  	}, []string{"phase"})
  2481  	registry.MustRegister(g)
  2482  
  2483  	// One series per HTTP phase, each with a distinct value.
  2484  	for i, phase := range []string{"resolve", "connect", "tls", "processing", "transfer"} {
  2485  		g.WithLabelValues(phase).Set(float64(i+1) * 0.01)
  2486  	}
  2487  
  2488  	return true, 1
  2489  }
  2490  
  2491  // TestScraperCollectDataReservedPhaseCollision is the realistic sibling of
  2492  // TestScraperCollectDataReservedLabelCollision: it collides a user label with
  2493  // the reserved per-series label `phase` on the multi-series
  2494  // probe_http_duration_seconds gauge instead of `le` on a histogram.
  2495  //
  2496  // It feeds a check label phase=user-value in UNPREFIXED mode and asserts the
  2497  // five per-phase series retain their distinct phase values.
  2498  //
  2499  // TODO(mem): remove this note. Like the `le` test above it FAILS against the
  2500  // current implementation: customMetricLabels seeds the user `phase` into
  2501  // executionMetricLabels ahead of each metric's own `phase` label
  2502  // (appendDtoToTimeseries appends the metric's labels after sharedLabels), and
  2503  // makeTimeseries' keep-first dedup drops the real phase, so all five series
  2504  // collapse to phase=user-value. It will pass once per-series reserved-name
  2505  // filtering is added to customMetricLabels.
  2506  func TestScraperCollectDataReservedPhaseCollision(t *testing.T) {
  2507  	s := Scraper{
  2508  		checkName:     "check name",
  2509  		target:        "test target",
  2510  		logger:        testhelper.Logger(t),
  2511  		prober:        phaseProber{},
  2512  		labelsLimiter: testLabelsLimiter{maxMetricLabels: 22, maxLogLabels: 15},
  2513  		labellingMode: testLabellingMode{mode: sm.LabelMode_LABEL_MODE_UNPREFIXED},
  2514  		summaries:     make(map[uint64]prometheus.Summary),
  2515  		histograms:    make(map[uint64]prometheus.Histogram),
  2516  		check: model.Check{
  2517  			Check: sm.Check{
  2518  				Id:               1,
  2519  				TenantId:         2,
  2520  				Frequency:        2000,
  2521  				Timeout:          2000,
  2522  				Enabled:          true,
  2523  				Target:           "target name",
  2524  				Job:              "job name",
  2525  				BasicMetricsOnly: false,
  2526  				Created:          42,
  2527  				Modified:         42,
  2528  				// User label collides with the reserved, per-series `phase`.
  2529  				Labels:   []sm.Label{{Name: "phase", Value: "user-value"}},
  2530  				Settings: sm.CheckSettings{Http: &sm.HttpSettings{}},
  2531  			},
  2532  		},
  2533  		probe: sm.Probe{
  2534  			Id:       100,
  2535  			TenantId: 200,
  2536  			Name:     "probe name",
  2537  			Region:   "REGION",
  2538  		},
  2539  	}
  2540  
  2541  	data, _, err := s.collectData(context.Background(), time.Unix(3141, 0))
  2542  	require.NoError(t, err)
  2543  	require.NotNil(t, data)
  2544  
  2545  	// Collect the `phase` value from every probe_http_duration_seconds series.
  2546  	phases := make(map[string]int)
  2547  	phaseSeries := 0
  2548  
  2549  	for _, ts := range data.Metrics() {
  2550  		name := findPromPbLabel(t, ts.GetLabels(), prom.MetricNameLabel)
  2551  		if name != "probe_http_duration_seconds" {
  2552  			continue
  2553  		}
  2554  
  2555  		phase := findPromPbLabel(t, ts.GetLabels(), "phase")
  2556  
  2557  		phaseSeries++
  2558  		phases[phase]++
  2559  	}
  2560  
  2561  	// Five phases emitted, so five distinct series.
  2562  	require.Equal(t, 5, phaseSeries, "expected one series per HTTP phase")
  2563  
  2564  	// The intended contract: each phase survives as its own series, un-clobbered
  2565  	// by the user's phase=user-value.
  2566  	require.Equal(t, map[string]int{
  2567  		"resolve": 1, "connect": 1, "tls": 1, "processing": 1, "transfer": 1,
  2568  	}, phases, "per-phase series were overwritten/collapsed by the user label")
  2569  }
  2570  
  2571  // these are generated using
  2572  // go run $(go env GOROOT)/src/crypto/tls/generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
  2573  
  2574  var localhostCert = []byte(`-----BEGIN CERTIFICATE-----
  2575  MIICFDCCAX2gAwIBAgIRAIoIa8inD4pBali2dBpc6+wwDQYJKoZIhvcNAQELBQAw
  2576  EjEQMA4GA1UEChMHQWNtZSBDbzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2
  2577  MDAwMFowEjEQMA4GA1UEChMHQWNtZSBDbzCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
  2578  gYkCgYEA5ii8ieKKayAtIX7BbKWB+zRDnJsFg02+cHwRy7nmX1t9MdObwNAMFIET
  2579  zvKz4Yctgi3WoyMZfp6pYugHHye829DbUsm+GI6Ca3zSzADfz+zL/nIWiJje0NB1
  2580  DXPASi03nTNk06O5RGlWeLqGKfyEY3xjcUrE7rKlNtLu8oK4Jm0CAwEAAaNoMGYw
  2581  DgYDVR0PAQH/BAQDAgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQF
  2582  MAMBAf8wLgYDVR0RBCcwJYILZXhhbXBsZS5jb22HBH8AAAGHEAAAAAAAAAAAAAAA
  2583  AAAAAAEwDQYJKoZIhvcNAQELBQADgYEAyW2Yc/vnlO9PYcXw9ZRYt51hTCKCooGv
  2584  RwR1OwL9Kdr/eY+wGpO+YsXxdSmRcmg467TA5d1YpssyVceKRla22NgDoZ2psFTe
  2585  LqKfcZUN+jvtQMx4LnsRZcz2i2U35Biq4h0i3SOgROIOEjQJJ6I8wMw9jD5kS86Q
  2586  IYZvskPli5s=
  2587  -----END CERTIFICATE-----`)
  2588  
  2589  var localhostKey = []byte(testingKey(`-----BEGIN RSA TESTING KEY-----
  2590  MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOYovIniimsgLSF+
  2591  wWylgfs0Q5ybBYNNvnB8Ecu55l9bfTHTm8DQDBSBE87ys+GHLYIt1qMjGX6eqWLo
  2592  Bx8nvNvQ21LJvhiOgmt80swA38/sy/5yFoiY3tDQdQ1zwEotN50zZNOjuURpVni6
  2593  hin8hGN8Y3FKxO6ypTbS7vKCuCZtAgMBAAECgYBzp1y2XOP5WL3U6wD/O1vJg0XG
  2594  WA+5H0Pm+jFnEg81M6ABfbfyd5jaZNIzV7oURf0UQTxt1aFmAwxS6w1JForLZn3g
  2595  PA3UVkDEZTl7C7h6kIY4PVzcki32V2YZ73e1zSCfvxIvbJ7SS697ua1sefIP5Gci
  2596  HNSRzanUyOKCZ1Or8QJBAPB1A/66Baydh+2nXddaad9d5Ifjvklk60tozLYpx7Il
  2597  R6NgU49Pa67MndGGykmbs094TYY8GswU2WoJVPUEYY8CQQD1CVB8++yK2jP0LkH/
  2598  s+3dag2S2IId5FwSbjiM8YtOFx2wN2HleX7Yr6ujPnKT+zy75eV0chPIP++QNKmy
  2599  eIJDAkAJm/OD63Usl8MF2UljwMY4We03DP/euPy6L772jKbhVKIPQls0f+0CuESa
  2600  SfOti15YD6uxcJd1jmO93A+cFwe7AkEAgLFnqHzXewWnC7PPzfA+GW+9uUYk8HYj
  2601  NTrWUI/7zgOuAALWU6M/z6ZTyuTdYIMvHrBblpDjeuS5eU9vYOCR6QJAWT7VShIT
  2602  sc8HRJOxUQWOI5hVUfLxVSosFDW7G5/75WlyeYwZOqOD54uNzavr8gpoTlvllUuP
  2603  GcRWUmpDEz0t6w==
  2604  -----END RSA TESTING KEY-----`))
  2605  
  2606  func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
  2607  
  2608  // testLogger impleents the Logger interface to pass it to the prober.
  2609  type testLogger struct {
  2610  	w io.Writer
  2611  }
  2612  
  2613  func (l *testLogger) Log(kv ...any) error {
  2614  	var buf strings.Builder
  2615  
  2616  	for i, v := range kv {
  2617  		if i >= 2 && i%2 == 0 {
  2618  			buf.WriteString(", ")
  2619  		}
  2620  
  2621  		switch v := v.(type) {
  2622  		case string:
  2623  			buf.WriteString(v)
  2624  
  2625  			if i%2 == 0 {
  2626  				buf.WriteRune(':')
  2627  			}
  2628  
  2629  		case error:
  2630  			buf.WriteString(v.Error())
  2631  
  2632  		case interface{ String() string }:
  2633  			buf.WriteString(v.String())
  2634  
  2635  		default:
  2636  			fmt.Fprintf(&buf, "%#v", v)
  2637  		}
  2638  	}
  2639  
  2640  	fmt.Fprintf(l.w, "%s\n", buf.String())
  2641  
  2642  	return nil
  2643  }
  2644  
  2645  func mergeMaps(mmaps ...map[string]string) map[string]string {
  2646  	out := make(map[string]string)
  2647  	for _, m := range mmaps {
  2648  		maps.Copy(out, m)
  2649  	}
  2650  
  2651  	return out
  2652  }
  2653  
  2654  func TestTruncateLabelValue(t *testing.T) {
  2655  	testcases := map[string]struct {
  2656  		length         int
  2657  		expectedLength int
  2658  	}{
  2659  		"zero": {
  2660  			length:         0,
  2661  			expectedLength: 0,
  2662  		},
  2663  		"one": {
  2664  			length:         1,
  2665  			expectedLength: 1,
  2666  		},
  2667  		"max/2": {
  2668  			length:         maxLabelValueLength / 2,
  2669  			expectedLength: maxLabelValueLength / 2,
  2670  		},
  2671  		"max-1": {
  2672  			length:         maxLabelValueLength - 1,
  2673  			expectedLength: maxLabelValueLength - 1,
  2674  		},
  2675  		"max": {
  2676  			length:         maxLabelValueLength,
  2677  			expectedLength: maxLabelValueLength,
  2678  		},
  2679  		"max+1": {
  2680  			length:         maxLabelValueLength + 1,
  2681  			expectedLength: maxLabelValueLength,
  2682  		},
  2683  		"2*max": {
  2684  			length:         2 * maxLabelValueLength,
  2685  			expectedLength: maxLabelValueLength,
  2686  		},
  2687  	}
  2688  
  2689  	for name, tc := range testcases {
  2690  		t.Run(name, func(t *testing.T) {
  2691  			input := strings.Repeat("a", tc.length)
  2692  			expected := strings.Repeat("a", tc.expectedLength)
  2693  			actual := truncateLabelValue(input)
  2694  			require.Equal(t, len(expected), len(actual))
  2695  
  2696  			if tc.expectedLength < tc.length {
  2697  				require.Equal(t, expected[:len(expected)-3], actual[:len(actual)-3])
  2698  				require.Equal(t, "...", actual[len(actual)-3:])
  2699  			}
  2700  		})
  2701  	}
  2702  }
  2703  
  2704  type testRunner struct {
  2705  	metrics []byte
  2706  	logs    []byte
  2707  }
  2708  
  2709  var _ k6runner.Runner = &testRunner{}
  2710  
  2711  func (r *testRunner) Run(ctx context.Context, script k6runner.Script, secretStore k6runner.SecretStore, _ string) (*k6runner.RunResponse, error) {
  2712  	return &k6runner.RunResponse{
  2713  		Metrics: r.metrics,
  2714  		Logs:    r.logs,
  2715  	}, nil
  2716  }
  2717  
  2718  func (r *testRunner) WithLogger(logger *zerolog.Logger) k6runner.Runner {
  2719  	return r
  2720  }
  2721  
  2722  func (*testRunner) Versions(_ context.Context) <-chan []string {
  2723  	return nil // Blocks forever if read.
  2724  }
  2725  
  2726  type testCounter struct {
  2727  	count atomic.Int32
  2728  }
  2729  
  2730  func (c *testCounter) Inc() {
  2731  	c.count.Add(1)
  2732  }
  2733  
  2734  type testCounterVec struct {
  2735  	counters map[string]Incrementer
  2736  	t        *testing.T
  2737  }
  2738  
  2739  func (c *testCounterVec) WithLabelValues(v ...string) Incrementer {
  2740  	require.Len(c.t, v, 1)
  2741  
  2742  	if _, found := c.counters[v[0]]; !found {
  2743  		c.counters[v[0]] = &testCounter{}
  2744  	}
  2745  
  2746  	return c.counters[v[0]]
  2747  }
  2748  
  2749  type testProberB struct {
  2750  	wantedFailures int32
  2751  	execCount      int32
  2752  	failureCount   int32
  2753  }
  2754  
  2755  func (p testProberB) Name() string {
  2756  	return "test prober"
  2757  }
  2758  
  2759  func (p *testProberB) Probe(ctx context.Context, target string, registry *prometheus.Registry, logger logger.Logger, _ string) (bool, float64) {
  2760  	p.execCount++
  2761  
  2762  	if p.failureCount < p.wantedFailures {
  2763  		p.failureCount++
  2764  		return false, 0
  2765  	}
  2766  
  2767  	return true, 1
  2768  }
  2769  
  2770  type testProbeFactory struct {
  2771  	builder func() prober.Prober
  2772  }
  2773  
  2774  func (f testProbeFactory) New(ctx context.Context, logger zerolog.Logger, check model.Check) (prober.Prober, string, error) {
  2775  	return f.builder(), check.Target, nil
  2776  }
  2777  
  2778  type testPublisher struct{}
  2779  
  2780  func (testPublisher) Publish(pusher.Payload) {
  2781  }
  2782  
  2783  type testTelemeter struct {
  2784  	execCount int32
  2785  	ee        []telemetry.Execution
  2786  }
  2787  
  2788  func (t *testTelemeter) AddExecution(e telemetry.Execution) {
  2789  	t.execCount++
  2790  }
  2791  
  2792  // TestScraperRun will set up a scraper in such a way that it runs 5 times, and fails 2 out of those 5.
  2793  //
  2794  // This checks that the probe gets run, and that the metrics are correctly collected.
  2795  func TestScraperRun(t *testing.T) {
  2796  	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
  2797  	t.Cleanup(cancel)
  2798  
  2799  	var check model.Check
  2800  
  2801  	err := check.FromSM(sm.Check{
  2802  		Id:        1,
  2803  		TenantId:  1000,
  2804  		Frequency: 100,
  2805  		Timeout:   1000,
  2806  		Enabled:   true,
  2807  		Target:    "127.0.0.1",
  2808  		Job:       "test",
  2809  		Settings: sm.CheckSettings{
  2810  			Ping: &sm.PingSettings{},
  2811  		},
  2812  	})
  2813  	require.NoError(t, err)
  2814  
  2815  	var counter testCounter
  2816  
  2817  	errCounter := testCounterVec{counters: make(map[string]Incrementer), t: t}
  2818  
  2819  	testProber := &testProberB{wantedFailures: 2}
  2820  	testTelemeter := &testTelemeter{}
  2821  
  2822  	metrics := NewMetrics(&counter, &errCounter)
  2823  
  2824  	s, err := NewWithOpts(ctx, check, ScraperOpts{
  2825  		Metrics:      metrics,
  2826  		ProbeFactory: testProbeFactory{builder: func() prober.Prober { return testProber }},
  2827  		Logger:       zerolog.New(zerolog.NewTestWriter(t)),
  2828  		Publisher:    &testPublisher{},
  2829  		LabelsLimiter: testLabelsLimiter{
  2830  			maxMetricLabels: 20,
  2831  			maxLogLabels:    15,
  2832  		},
  2833  		LabellingMode: testLabellingMode{},
  2834  		Telemeter:     testTelemeter,
  2835  		CostAttributionLabels: testCalTenants{
  2836  			costAttributionLabels: []string{"testing", "you"},
  2837  		},
  2838  	})
  2839  
  2840  	require.NoError(t, err)
  2841  	require.NotNil(t, s)
  2842  
  2843  	s.Run(ctx)
  2844  
  2845  	require.Equal(t, testProber.execCount, counter.count.Load())
  2846  	require.Len(t, errCounter.counters, 1)
  2847  	checkErrCounter, found := errCounter.counters["check"]
  2848  	require.True(t, found)
  2849  	require.Equal(t, testProber.failureCount, checkErrCounter.(*testCounter).count.Load())
  2850  
  2851  	// Verify telemetry
  2852  	require.Equal(t, counter.count.Load(), testTelemeter.execCount)
  2853  
  2854  	for _, e := range testTelemeter.ee {
  2855  		require.Equal(t, 1000, e.LocalTenantID)
  2856  		require.Equal(t, check.Class(), e.CheckClass)
  2857  		require.Greater(t, 0, e.Duration)
  2858  	}
  2859  }
  2860  
  2861  func TestTickWithOffset(t *testing.T) {
  2862  	const (
  2863  		WORK    = 1
  2864  		IDLE    = 2
  2865  		CLEANUP = 3
  2866  	)
  2867  
  2868  	testcases := map[string]struct {
  2869  		timeout  time.Duration
  2870  		period   time.Duration
  2871  		offset   time.Duration
  2872  		maxIdle  time.Duration
  2873  		minGap   time.Duration
  2874  		expected []int
  2875  	}{
  2876  		"An idle worker running between regular runs": {
  2877  			timeout: 1050 * time.Millisecond,
  2878  			period:  500 * time.Millisecond,
  2879  			offset:  1,
  2880  			maxIdle: 100 * time.Millisecond,
  2881  			minGap:  50 * time.Millisecond,
  2882  			expected: []int{
  2883  				WORK, // 0
  2884  				IDLE, // 100
  2885  				IDLE, // 200
  2886  				IDLE, // 300
  2887  				IDLE, // 400
  2888  				WORK, // 500
  2889  				IDLE, // 600
  2890  				IDLE, // 700
  2891  				IDLE, // 800
  2892  				IDLE, // 900
  2893  				WORK, // 1000
  2894  				CLEANUP,
  2895  			},
  2896  		},
  2897  		"An idle worker trying to run within gap duration of regular runs.": {
  2898  			timeout: 1050 * time.Millisecond,
  2899  			period:  500 * time.Millisecond,
  2900  			offset:  1,
  2901  			maxIdle: 100 * time.Millisecond,
  2902  			minGap:  150 * time.Millisecond,
  2903  			expected: []int{
  2904  				WORK, // 0
  2905  				IDLE, // 200, there's no idle at 100 because it's too close to the previous run
  2906  				IDLE, // 300
  2907  				WORK, // 500, there's no idle at 400 because it's too close to the next run
  2908  				IDLE, // 700, there's no idle at 600 because it's too close to the previous run
  2909  				IDLE, // 800
  2910  				WORK, // 1000, there's no idle at 900 because it's too close to the next run
  2911  				CLEANUP,
  2912  			},
  2913  		},
  2914  		"A zero offset and a scraper that has already been cancelled.": {
  2915  			timeout:  0, // this asks for immediate cancellation
  2916  			period:   500 * time.Millisecond,
  2917  			offset:   0,
  2918  			maxIdle:  100 * time.Millisecond,
  2919  			minGap:   150 * time.Millisecond,
  2920  			expected: nil,
  2921  		},
  2922  		"A zero offset and a short timeout.": {
  2923  			timeout: 25 * time.Millisecond,
  2924  			period:  50 * time.Millisecond,
  2925  			offset:  0,
  2926  			maxIdle: 100 * time.Millisecond,
  2927  			minGap:  150 * time.Millisecond,
  2928  			expected: []int{
  2929  				WORK, // 0
  2930  				CLEANUP,
  2931  			},
  2932  		},
  2933  		"A zero offset and a larger timeout.": {
  2934  			timeout: 200 * time.Millisecond,
  2935  			period:  75 * time.Millisecond,
  2936  			offset:  0,
  2937  			maxIdle: 100 * time.Millisecond,
  2938  			minGap:  150 * time.Millisecond,
  2939  			expected: []int{
  2940  				WORK, // 0
  2941  				WORK, // 75
  2942  				WORK, // 150
  2943  				CLEANUP,
  2944  			},
  2945  		},
  2946  	}
  2947  
  2948  	for name, tc := range testcases {
  2949  		t.Run(name, func(t *testing.T) {
  2950  			ctx, cancel := testhelper.Context(context.Background(), t)
  2951  			t.Cleanup(cancel)
  2952  
  2953  			// Append a particular marker to the results slice
  2954  			// depending on which function was called by
  2955  			// TestTickWithOffset. This allows us to test that the
  2956  			// correct function is being called in the correct
  2957  			// order.
  2958  			var results []int
  2959  
  2960  			work := func(context.Context, time.Time) { results = append(results, WORK) }
  2961  			idle := func(context.Context, time.Time) { results = append(results, IDLE) }
  2962  			cleanup := func(context.Context, time.Time) { results = append(results, CLEANUP) }
  2963  
  2964  			stop := make(chan struct{})
  2965  
  2966  			if tc.timeout > 0 {
  2967  				go func() {
  2968  					time.Sleep(tc.timeout)
  2969  					close(stop)
  2970  				}()
  2971  			} else {
  2972  				// why not in the goroutine? Because we need to
  2973  				// make sure the channel is closed *before*
  2974  				// calling tickWithOffset
  2975  				close(stop)
  2976  			}
  2977  
  2978  			tickWithOffset(ctx, stop, work, idle, cleanup, tc.period, tc.offset, tc.maxIdle, tc.minGap)
  2979  
  2980  			require.Equal(t, tc.expected, results)
  2981  		})
  2982  	}
  2983  }
  2984  
  2985  func TestGetCostAttributionLabels(t *testing.T) {
  2986  	t.Parallel()
  2987  
  2988  	testCases := []struct {
  2989  		name           string
  2990  		tenantCALs     []string
  2991  		checkLabels    []sm.Label
  2992  		expectedResult []sm.CostAttributionLabel
  2993  	}{
  2994  		{
  2995  			name:           "no CALs for tenant returns empty slice",
  2996  			tenantCALs:     []string{},
  2997  			checkLabels:    []sm.Label{{Name: "env", Value: "prod"}},
  2998  			expectedResult: []sm.CostAttributionLabel{},
  2999  		},
  3000  		{
  3001  			name:        "no matching labels returns all CALs with nil terminator",
  3002  			tenantCALs:  []string{"env", "team"},
  3003  			checkLabels: []sm.Label{{Name: "unrelated", Value: "value"}},
  3004  			expectedResult: []sm.CostAttributionLabel{
  3005  				{Name: "env", Value: telemetry.CalNilStringTerminator},
  3006  				{Name: "team", Value: telemetry.CalNilStringTerminator},
  3007  			},
  3008  		},
  3009  		{
  3010  			name:        "some matching labels returns mix of values and nil terminators",
  3011  			tenantCALs:  []string{"env", "team"},
  3012  			checkLabels: []sm.Label{{Name: "env", Value: "prod"}},
  3013  			expectedResult: []sm.CostAttributionLabel{
  3014  				{Name: "env", Value: "prod"},
  3015  				{Name: "team", Value: telemetry.CalNilStringTerminator},
  3016  			},
  3017  		},
  3018  		{
  3019  			name:       "all matching labels returns all values",
  3020  			tenantCALs: []string{"env", "team"},
  3021  			checkLabels: []sm.Label{
  3022  				{Name: "env", Value: "prod"},
  3023  				{Name: "team", Value: "platform"},
  3024  			},
  3025  			expectedResult: []sm.CostAttributionLabel{
  3026  				{Name: "env", Value: "prod"},
  3027  				{Name: "team", Value: "platform"},
  3028  			},
  3029  		},
  3030  	}
  3031  
  3032  	for _, tc := range testCases {
  3033  		t.Run(tc.name, func(t *testing.T) {
  3034  			t.Parallel()
  3035  
  3036  			calsProvider := &mockTenantCals{cals: tc.tenantCALs}
  3037  
  3038  			s := &Scraper{
  3039  				cals:  calsProvider,
  3040  				check: model.Check{Check: sm.Check{Labels: tc.checkLabels}},
  3041  			}
  3042  
  3043  			result, err := s.getCostAttributionLabels(context.Background(), 1)
  3044  			require.NoError(t, err)
  3045  			require.ElementsMatch(t, tc.expectedResult, result)
  3046  		})
  3047  	}
  3048  }
  3049  
  3050  type mockTenantCals struct {
  3051  	cals []string
  3052  }
  3053  
  3054  func (m *mockTenantCals) CostAttributionLabels(ctx context.Context, tenantID model.GlobalID) ([]string, error) {
  3055  	return m.cals, nil
  3056  }
  3057  
  3058  func TestExtractLogsK6TimeOverridesDefaultTimestamp(t *testing.T) {
  3059  	// Test that k6's "time" field overrides the default "ts" timestamp from the logger
  3060  	testCases := []struct {
  3061  		name         string
  3062  		logs         string
  3063  		sharedLabels []labelPair
  3064  		expected     func(t *testing.T, streams Streams)
  3065  	}{
  3066  		{
  3067  			name: "k6 time field overrides default ts",
  3068  			logs: `ts=2023-06-01T20:00:00Z time="2023-06-01T13:40:26-06:00" level=info msg="k6 message"`,
  3069  			sharedLabels: []labelPair{
  3070  				{name: "probe", value: "test-probe"},
  3071  			},
  3072  			expected: func(t *testing.T, streams Streams) {
  3073  				require.Len(t, streams, 1)
  3074  				require.Len(t, streams[0].Entries, 1)
  3075  				entry := streams[0].Entries[0]
  3076  
  3077  				expectedTime, _ := time.Parse(time.RFC3339, "2023-06-01T13:40:26-06:00")
  3078  				require.Equal(t, expectedTime, entry.Timestamp, "should use k6 time field, not default ts")
  3079  				require.Equal(t, entry.Line, "level=info msg=\"k6 message\"\n")
  3080  			},
  3081  		},
  3082  		{
  3083  			name: "k6 time field before ts (order independence)",
  3084  			// Defensive case: time= appears before ts= in the log line. The guard on the ts= case
  3085  			// (if !t.IsZero() { continue }) ensures ts= does not overwrite a time= already seen.
  3086  			logs: `time="2023-06-01T13:40:26-06:00" ts=2023-06-01T20:00:00Z level=info msg="k6 message"`,
  3087  			sharedLabels: []labelPair{
  3088  				{name: "probe", value: "test-probe"},
  3089  			},
  3090  			expected: func(t *testing.T, streams Streams) {
  3091  				require.Len(t, streams, 1)
  3092  				require.Len(t, streams[0].Entries, 1)
  3093  				entry := streams[0].Entries[0]
  3094  
  3095  				expectedTime, _ := time.Parse(time.RFC3339, "2023-06-01T13:40:26-06:00")
  3096  				require.Equal(t, expectedTime, entry.Timestamp, "should use k6 time field even when it precedes ts")
  3097  				require.Equal(t, entry.Line, "level=info msg=\"k6 message\"\n")
  3098  			},
  3099  		},
  3100  		{
  3101  			name: "BBE adapter time field with nanosecond precision is used as timestamp",
  3102  			logs: `ts=2023-06-01T20:00:00.500000000Z level=INFO msg="Making HTTP request" time=2023-06-01T20:00:00.600000000Z url=https://example.com host=example.com`,
  3103  			sharedLabels: []labelPair{
  3104  				{name: "probe", value: "test-probe"},
  3105  			},
  3106  			expected: func(t *testing.T, streams Streams) {
  3107  				require.Len(t, streams, 1)
  3108  				require.Len(t, streams[0].Entries, 1)
  3109  				entry := streams[0].Entries[0]
  3110  
  3111  				// time= (600ms) should win over ts= (500ms), and must preserve nanosecond precision.
  3112  				expectedTime, _ := time.Parse(time.RFC3339Nano, "2023-06-01T20:00:00.600000000Z")
  3113  				require.Equal(t, expectedTime, entry.Timestamp, "should use nanosecond-precision time= field")
  3114  				// url= and host= should be in the body; time= should not.
  3115  				require.Contains(t, entry.Line, "url=https://example.com")
  3116  				require.Contains(t, entry.Line, "host=example.com")
  3117  				require.NotContains(t, entry.Line, "time=")
  3118  			},
  3119  		},
  3120  		{
  3121  			name: "ts field only (no k6 time)",
  3122  			logs: `ts=2023-06-01T20:00:00Z level=info msg="normal message"`,
  3123  			sharedLabels: []labelPair{
  3124  				{name: "probe", value: "test-probe"},
  3125  			},
  3126  			expected: func(t *testing.T, streams Streams) {
  3127  				require.Len(t, streams, 1)
  3128  				require.Len(t, streams[0].Entries, 1)
  3129  				entry := streams[0].Entries[0]
  3130  
  3131  				expectedTime, _ := time.Parse(time.RFC3339Nano, "2023-06-01T20:00:00Z")
  3132  				require.Equal(t, expectedTime, entry.Timestamp)
  3133  				require.Equal(t, entry.Line, "level=info msg=\"normal message\"\n")
  3134  			},
  3135  		},
  3136  	}
  3137  
  3138  	for _, tc := range testCases {
  3139  		t.Run(tc.name, func(t *testing.T) {
  3140  			t.Parallel()
  3141  
  3142  			s := Scraper{
  3143  				logger: testhelper.Logger(t),
  3144  			}
  3145  
  3146  			streams := s.extractLogs(time.Now(), []byte(tc.logs), tc.sharedLabels, nil)
  3147  			tc.expected(t, streams)
  3148  		})
  3149  	}
  3150  }
  3151  
  3152  func TestHTTPCheckLogTimestampsAreNonDecreasing(t *testing.T) {
  3153  	ctx := context.Background()
  3154  
  3155  	p, check, cleanup := setupHTTPProbe(ctx, t)
  3156  	defer cleanup()
  3157  
  3158  	s := Scraper{
  3159  		checkName: check.Settings.Http.String(),
  3160  		target:    check.Target,
  3161  		logger:    testhelper.Logger(t),
  3162  		prober:    p,
  3163  		labelsLimiter: testLabelsLimiter{
  3164  			maxMetricLabels: 20,
  3165  			maxLogLabels:    15,
  3166  		},
  3167  		labellingMode: testLabellingMode{},
  3168  		summaries:     make(map[uint64]prometheus.Summary),
  3169  		histograms:    make(map[uint64]prometheus.Histogram),
  3170  		check:         check,
  3171  		probe: sm.Probe{
  3172  			Name:   "test-probe",
  3173  			Region: "test-region",
  3174  		},
  3175  	}
  3176  
  3177  	// A scheduled scrape may carry an older ticker value for metric stamping,
  3178  	// but its wrapper logs must retain their original wall-clock behavior. The
  3179  	// unscheduled collector has separate coverage for logical timestamps.
  3180  	wallStart := time.Now()
  3181  	data, _, err := s.collectData(ctx, time.Date(2020, 3, 1, 12, 0, 0, 0, time.UTC))
  3182  	require.NoError(t, err)
  3183  	require.NotNil(t, data)
  3184  
  3185  	streams := data.Streams()
  3186  	require.NotEmpty(t, streams, "expected at least one log stream")
  3187  
  3188  	for _, stream := range streams {
  3189  		entries := stream.Entries
  3190  		require.NotEmpty(t, entries, "expected log entries in stream")
  3191  
  3192  		// All 7 expected messages must be present.
  3193  		msgs := make([]string, 0, len(entries))
  3194  		for _, e := range entries {
  3195  			if strings.Contains(e.Line, `msg="Beginning check"`) || strings.Contains(e.Line, `msg="Check succeeded"`) {
  3196  				require.False(t, e.Timestamp.Before(wallStart), "scheduled wrapper log used the logical metric timestamp: %s", e.Timestamp)
  3197  			}
  3198  
  3199  			dec := logfmt.NewDecoder(strings.NewReader(e.Line))
  3200  			for dec.ScanRecord() {
  3201  				for dec.ScanKeyval() {
  3202  					if string(dec.Key()) == "msg" {
  3203  						msgs = append(msgs, string(dec.Value()))
  3204  					}
  3205  				}
  3206  			}
  3207  		}
  3208  
  3209  		expectedMsgs := []string{
  3210  			"Beginning check",
  3211  			"Resolving target address",
  3212  			"Resolved target address",
  3213  			"Making HTTP request",
  3214  			"Received HTTP response",
  3215  			"Response timings for roundtrip",
  3216  			"Check succeeded",
  3217  		}
  3218  		for _, expected := range expectedMsgs {
  3219  			require.Contains(t, msgs, expected, "missing log entry: %q", expected)
  3220  		}
  3221  
  3222  		// Entry timestamps must be non-decreasing (Loki stream ordering requirement).
  3223  		for i := 1; i < len(entries); i++ {
  3224  			require.False(t,
  3225  				entries[i].Timestamp.Before(entries[i-1].Timestamp),
  3226  				"entry[%d] timestamp %v is before entry[%d] timestamp %v (out-of-order)\nentry[%d] msg: %s\nentry[%d] msg: %s",
  3227  				i, entries[i].Timestamp,
  3228  				i-1, entries[i-1].Timestamp,
  3229  				i-1, entries[i-1].Line,
  3230  				i, entries[i].Line,
  3231  			)
  3232  		}
  3233  	}
  3234  }