From c706944ce7ddf33bccb6326ea6f28e5aac509ec9 Mon Sep 17 00:00:00 2001 From: bwplotka Date: Mon, 17 Aug 2026 09:56:27 +0100 Subject: [PATCH] remotewrite/sender: Migrate Remote Write 2.0 sender tests to new framework - Migrate all remaining sender tests to the new slice-based Test structure in compliance.go - Align test expectations with the Prometheus Remote-Write 2.0 Specification - Remove outdated rules and heuristics not required by the specification Signed-off-by: bwplotka --- remotewrite/receiver/exemplar_test.go | 4 +- remotewrite/receiver/histograms_test.go | 6 +- remotewrite/receiver/metadata_test.go | 6 +- remotewrite/receiver/metric_test.go | 6 +- remotewrite/sender/backoff_test.go | 219 ++---------- remotewrite/sender/batching_test.go | 185 +--------- remotewrite/sender/combined_test.go | 320 ++--------------- remotewrite/sender/compliance.go | 15 + remotewrite/sender/edge_cases_test.go | 408 ++++------------------ remotewrite/sender/error_handling_test.go | 225 +----------- remotewrite/sender/exemplars_test.go | 226 ++---------- remotewrite/sender/fallback_test.go | 320 +---------------- remotewrite/sender/histograms_test.go | 286 ++------------- remotewrite/sender/labels_test.go | 277 ++------------- remotewrite/sender/metadata_test.go | 294 ++-------------- remotewrite/sender/protocol_test.go | 142 ++------ remotewrite/sender/response_test.go | 268 +------------- remotewrite/sender/retry_test.go | 233 +++--------- remotewrite/sender/rw1_compat_test.go | 232 +----------- remotewrite/sender/symbols_test.go | 145 ++------ 20 files changed, 432 insertions(+), 3385 deletions(-) diff --git a/remotewrite/receiver/exemplar_test.go b/remotewrite/receiver/exemplar_test.go index cb208d76..2f8b449d 100644 --- a/remotewrite/receiver/exemplar_test.go +++ b/remotewrite/receiver/exemplar_test.go @@ -114,8 +114,8 @@ func TestExemplarWithCreatedTimestamp(t *testing.T) { createdTime := now.Add(-2 * time.Hour) sample := SampleWithLabels{ - Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, - Value: 250.0, + Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, + Value: 250.0, StartTimestamp: &createdTime, } exemplar := ExemplarWithLabels{ diff --git a/remotewrite/receiver/histograms_test.go b/remotewrite/receiver/histograms_test.go index a8327e6a..09eea517 100644 --- a/remotewrite/receiver/histograms_test.go +++ b/remotewrite/receiver/histograms_test.go @@ -118,9 +118,9 @@ func TestHistogramWithCreatedTimestamp(t *testing.T) { createdTime := now.Add(-30 * time.Minute) hist := HistogramWithLabels{ - Labels: map[string]string{"__name__": "request_duration_seconds", "job": "api"}, - Histogram: histogram(1.5, true, true, true, false, false), - StartTimestamp: &createdTime, + Labels: map[string]string{"__name__": "request_duration_seconds", "job": "api"}, + Histogram: histogram(1.5, true, true, true, false, false), + StartTimestamp: &createdTime, } runComplianceTest(t, "", "Histogram with created timestamp", diff --git a/remotewrite/receiver/metadata_test.go b/remotewrite/receiver/metadata_test.go index 1e6a3c79..4a429527 100644 --- a/remotewrite/receiver/metadata_test.go +++ b/remotewrite/receiver/metadata_test.go @@ -159,9 +159,9 @@ func TestCounterMetadataWithCreatedTimestamp(t *testing.T) { createdTime := now.Add(-1 * time.Hour) sample := SampleWithLabels{ - Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, - Value: 150.0, - StartTimestamp: &createdTime, + Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, + Value: 150.0, + StartTimestamp: &createdTime, } metadata := MetadataWithLabels{ Labels: basicMetric("http_requests_total"), diff --git a/remotewrite/receiver/metric_test.go b/remotewrite/receiver/metric_test.go index 5462b3f1..069de57e 100644 --- a/remotewrite/receiver/metric_test.go +++ b/remotewrite/receiver/metric_test.go @@ -95,9 +95,9 @@ func TestCounterWithCreatedTimestamp(t *testing.T) { createdTime := now.Add(-1 * time.Hour) sample := SampleWithLabels{ - Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, - Value: 100.0, - StartTimestamp: &createdTime, + Labels: map[string]string{"__name__": "http_requests_total", "job": "api"}, + Value: 100.0, + StartTimestamp: &createdTime, } runComplianceTest(t, "", "Counter with created timestamp", diff --git a/remotewrite/sender/backoff_test.go b/remotewrite/sender/backoff_test.go index 642e9291..92ffd591 100644 --- a/remotewrite/sender/backoff_test.go +++ b/remotewrite/sender/backoff_test.go @@ -14,219 +14,46 @@ package sender import ( - "fmt" "net/http" - "sync" "testing" "time" -) - -// TimestampTrackingReceiver wraps MockReceiver to track request timestamps. -type TimestampTrackingReceiver struct { - *MockReceiver - mu sync.Mutex - timestamps []time.Time -} -// NewTimestampTrackingReceiver creates a receiver that tracks request timestamps. -func NewTimestampTrackingReceiver(baseReceiver *MockReceiver) *TimestampTrackingReceiver { - return &TimestampTrackingReceiver{ - MockReceiver: baseReceiver, - timestamps: make([]time.Time, 0), - } -} - -// RecordTimestamp records the current time for a request. -func (ttr *TimestampTrackingReceiver) RecordTimestamp() { - ttr.mu.Lock() - defer ttr.mu.Unlock() - ttr.timestamps = append(ttr.timestamps, time.Now()) -} - -// GetTimestamps returns all recorded timestamps. -func (ttr *TimestampTrackingReceiver) GetTimestamps() []time.Time { - ttr.mu.Lock() - defer ttr.mu.Unlock() - return append([]time.Time{}, ttr.timestamps...) -} - -// TestBackoffBehavior validates backoff and exponential backoff implementation. -func TestBackoffBehavior_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []struct { - name string - description string - rfcLevel string - scrapeData string - setup func(*MockReceiver) - validator func(*testing.T, *TimestampTrackingReceiver) - }{ +func backoffTests() []Test { + return []Test{ { - name: "exponential_backoff_on_retries", - description: "Sender SHOULD use exponential backoff when retrying", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ + Name: "backoff_required", + Description: "Sender MUST use a backoff algorithm to prevent overwhelming the server", + RFCLevel: MustLevel, + ScrapeData: "test_metric 42\n", + Version: remote.WriteV2MessageType, + TestResponses: []ReceiverResponse{ + { StatusCode: http.StatusServiceUnavailable, Body: "Service unavailable", - }) - }, - validator: func(t *testing.T, ttr *TimestampTrackingReceiver) { - timestamps := ttr.GetTimestamps() - if len(timestamps) < 3 { - should(t, len(timestamps) >= 3, "Need at least 3 requests to validate backoff pattern") - t.Logf("Only %d requests observed, cannot validate backoff", len(timestamps)) - return - } - - // Calculate intervals between requests. - intervals := make([]time.Duration, 0) - for i := 1; i < len(timestamps); i++ { - interval := timestamps[i].Sub(timestamps[i-1]) - intervals = append(intervals, interval) - t.Logf("Interval %d: %v", i, interval) - } - - // Check that intervals are increasing (exponential backoff). - if len(intervals) >= 2 { - for i := 1; i < len(intervals); i++ { - // Allow some tolerance for timing jitter. - // Second interval should be >= first interval (or close). - ratio := float64(intervals[i]) / float64(intervals[i-1]) - should(t, ratio >= 0.8, fmt.Sprintf("Backoff intervals should increase or stay similar (exponential), got ratio %.2f", ratio)) - } - } - }, - }, - { - name: "backoff_required", - description: "Sender MUST use a backoff algorithm to prevent overwhelming the server", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ + }, + { StatusCode: http.StatusServiceUnavailable, Body: "Service unavailable", - }) + }, + { + StatusCode: http.StatusNoContent, + }, }, - validator: func(t *testing.T, ttr *TimestampTrackingReceiver) { - timestamps := ttr.GetTimestamps() - must(t).True(len(timestamps) >= 2, "Sender must retry on 5xx errors") - - if len(timestamps) < 2 { - t.Fatalf("Expected at least 2 requests (initial + retry), got %d", len(timestamps)) - } + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 3, "Expected at least 3 requests (initial + 2 retries)") // MUST: Verify that backoff exists (delay between all requests). minBackoffDelay := 1 * time.Millisecond - for i := 1; i < len(timestamps); i++ { - interval := timestamps[i].Sub(timestamps[i-1]) - must(t).True(interval >= minBackoffDelay, + for i := 1; i < len(res.Requests); i++ { + interval := res.Requests[i].Received.Sub(res.Requests[i-1].Received) + require.GreaterOrEqual(t, interval, minBackoffDelay, "Sender MUST implement backoff between all retry attempts, interval %d was: %v", i, interval) - t.Logf("Interval %d: %v", i, interval) - } - - // Additional validation: Check that delays increase over time (if 3+ requests). - if len(timestamps) >= 3 { - firstDelay := timestamps[1].Sub(timestamps[0]) - secondDelay := timestamps[2].Sub(timestamps[1]) - t.Logf("First delay: %v, Second delay: %v", firstDelay, secondDelay) - - // This validates increasing backoff pattern - if secondDelay < firstDelay { - t.Logf("Note: Second delay (%v) is shorter than first delay (%v), backoff may not be increasing", secondDelay, firstDelay) - } - } - }, - }, - { - name: "backoff_max_delay", - description: "Backoff SHOULD have a reasonable maximum delay", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadGateway, - Body: "Bad gateway", - }) - }, - validator: func(t *testing.T, ttr *TimestampTrackingReceiver) { - timestamps := ttr.GetTimestamps() - if len(timestamps) < 2 { - t.Logf("Only %d requests, cannot validate max delay", len(timestamps)) - return - } - - // Check that no interval exceeds a reasonable maximum (e.g., 60 seconds). - maxReasonableDelay := 60 * time.Second - - for i := 1; i < len(timestamps); i++ { - interval := timestamps[i].Sub(timestamps[i-1]) - should(t, interval <= maxReasonableDelay, fmt.Sprintf("Backoff interval too large: %v > %v", interval, maxReasonableDelay)) - } - - t.Logf("Observed %d retry attempts over %v", - len(timestamps), timestamps[len(timestamps)-1].Sub(timestamps[0])) - }, - }, - { - name: "backoff_with_jitter", - description: "Sender MAY add jitter to backoff delays", - rfcLevel: "RECOMMENDED", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusServiceUnavailable, - Body: "Service unavailable", - }) - }, - validator: func(t *testing.T, ttr *TimestampTrackingReceiver) { - timestamps := ttr.GetTimestamps() - if len(timestamps) < 3 { - may(t, len(timestamps) >= 3, "Jitter validation requires multiple retries") - return - } - - intervals := make([]time.Duration, 0) - for i := 1; i < len(timestamps); i++ { - intervals = append(intervals, timestamps[i].Sub(timestamps[i-1])) } - - // With jitter, intervals shouldn't be exactly doubling. - may(t, len(intervals) > 0, "Intervals should exist for jitter analysis") - t.Logf("Intervals: %v (jitter may cause variance)", intervals) }, }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - t.Attr("rfcLevel", tt.rfcLevel) - t.Attr("description", tt.description) - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - tracker := NewTimestampTrackingReceiver(receiver) - - tt.setup(receiver) - - originalHandler := receiver.server.Config.Handler - receiver.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracker.RecordTimestamp() - originalHandler.ServeHTTP(w, r) - }) - - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - runAutoTargetWithCustomReceiver(t, targetName, target, receiver.URL(), scrapeTarget, 20*time.Second) - tt.validator(t, tracker) - }) - }) - } } diff --git a/remotewrite/sender/batching_test.go b/remotewrite/sender/batching_test.go index 07b901d8..0bf7e983 100644 --- a/remotewrite/sender/batching_test.go +++ b/remotewrite/sender/batching_test.go @@ -14,20 +14,18 @@ package sender import ( - "fmt" - "strings" "testing" - "time" + + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" ) -// TestBatchingBehavior validates sender batching and queueing behavior. -func TestBatchingBehavior_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - tests := []TestCase{ +func batchingTests() []Test { + return []Test{ { Name: "multiple_series_per_request", - Description: "Sender should batch multiple series in single request for efficiency", - RFCLevel: "RECOMMENDED", + Description: "Senders SHOULD use Remote-Write to send samples for multiple series in a single request.", + RFCLevel: ShouldLevel, ScrapeData: `# Multiple metrics to batch http_requests_total{method="GET",status="200"} 1000 http_requests_total{method="POST",status="200"} 500 @@ -36,167 +34,20 @@ cpu_usage_percent 45.2 memory_usage_bytes 1048576 disk_io_bytes_total 1000000 `, - Validator: func(t *testing.T, req *CapturedRequest) { - // Count unique metric names. - metricNames := make(map[string]bool) - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricNames[labels["__name__"]] = true - } - - recommended(t, len(req.Request.Timeseries) >= 3, fmt.Sprintf("Sender should batch multiple series, got %d series", len(req.Request.Timeseries))) - recommended(t, len(metricNames) >= 2, fmt.Sprintf("Sender should batch different metrics, got %d unique metrics", len(metricNames))) - - t.Logf("Batched %d timeseries with %d unique metrics", - len(req.Request.Timeseries), len(metricNames)) - }, - }, - { - Name: "batch_size_reasonable", - Description: "Sender should use reasonable batch sizes (10k series max) for performance", - RFCLevel: "RECOMMENDED", - ScrapeData: func() string { - var ret strings.Builder - ret.WriteString("# Large scrape to test batch size handling\n") - for i := range 12000 { - fmt.Fprintf(&ret, "metric{label=\"%d\"} 1\n", i) - } - return ret.String() - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - seriesCount := len(req.Request.Timeseries) - - // Batches shouldn't be too small (inefficient) or too large (risk). - recommended(t, seriesCount >= 1, "Request should contain at least one series") - - recommended(t, seriesCount <= 10000, fmt.Sprintf("Batch size should be reasonable (less than 10k series), got %d", seriesCount)) - - t.Logf("Batch contains %d timeseries from 12k available metrics", seriesCount) - }, - }, - { - Name: "handles_varying_cardinality", - Description: "Sender should handle varying label cardinality efficiently", - RFCLevel: "RECOMMENDED", - ScrapeData: `# High cardinality metrics -api_calls{endpoint="/users",method="GET",region="us-east",status="200"} 100 -api_calls{endpoint="/users",method="POST",region="us-east",status="201"} 50 -api_calls{endpoint="/posts",method="GET",region="us-west",status="200"} 200 -api_calls{endpoint="/posts",method="DELETE",region="eu-west",status="204"} 10 -api_calls{endpoint="/comments",method="GET",region="ap-south",status="200"} 500 -low_cardinality_metric 42 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Check symbol table efficiency with varying cardinality. - symbols := req.Request.Symbols - uniqueSymbols := make(map[string]bool) - for _, sym := range symbols { - if sym != "" { - uniqueSymbols[sym] = true - } - } - - recommended(t, len(uniqueSymbols) > 0, "Symbol table should deduplicate") - recommended(t, len(req.Request.Timeseries) >= 2, "Should handle mixed cardinality metrics") - - t.Logf("Symbol table: %d unique symbols for %d timeseries", - len(uniqueSymbols), len(req.Request.Timeseries)) - }, - }, - { - Name: "efficient_symbol_reuse", - Description: "Sender should reuse symbols efficiently across batches", - RFCLevel: "RECOMMENDED", - ScrapeData: `# Metrics with shared labels -http_requests{service="api",method="GET"} 100 -http_requests{service="api",method="POST"} 50 -http_requests{service="web",method="GET"} 200 -http_duration{service="api",method="GET"} 0.5 -http_duration{service="api",method="POST"} 0.3 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - - // Count occurrences of common strings. - symbolCounts := make(map[string]int) - for _, sym := range symbols { - symbolCounts[sym]++ - } - - // Common strings should appear only once (deduplicated). - for sym, count := range symbolCounts { - if sym != "" { - recommended(t, count == 1, fmt.Sprintf("Symbol %q should appear only once in table, got %d", sym, count)) + Version: remote.WriteV2MessageType, + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 1, "Should receive at least 1 request") + + // At least one request should batch multiple series + batched := false + for _, req := range res.Requests { + if req.RW2 != nil && len(req.RW2.Timeseries) >= 3 { + batched = true + break } } - - t.Logf("Symbol table efficiency: %d unique symbols", len(symbols)) - }, - }, - { - Name: "metadata_batching", - Description: "Sender should batch metadata with samples efficiently", - RFCLevel: "RECOMMENDED", - ScrapeData: `# HELP http_requests_total Total HTTP requests -# TYPE http_requests_total counter -http_requests_total{method="GET"} 1000 -http_requests_total{method="POST"} 500 - -# HELP memory_usage_bytes Current memory usage -# TYPE memory_usage_bytes gauge -memory_usage_bytes 1048576 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var withMetadata int - var withoutMetadata int - - for _, ts := range req.Request.Timeseries { - // Count timeseries with metadata. - hasMetadata := ts.Metadata.Type != 0 || - ts.Metadata.HelpRef != 0 || - ts.Metadata.UnitRef != 0 - - if hasMetadata { - withMetadata++ - } else { - withoutMetadata++ - } - } - - recommended(t, len(req.Request.Timeseries) >= 2, "Should batch multiple series") - - t.Logf("Batched timeseries: %d with metadata, %d without", - withMetadata, withoutMetadata) + require.True(t, batched, "Sender should batch multiple series") }, }, } - - runTestCases(t, tests) -} - -// TestConcurrentRequests validates parallel request handling. -func TestConcurrentRequests_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "MAY") - t.Attr("description", "Sender MAY send multiple requests in parallel") - - scrapeData := `# Multiple metrics -metric_1 1 -metric_2 2 -metric_3 3 -metric_4 4 -metric_5 5 -` - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - runSenderTest(t, targetName, target, SenderTestScenario{ - ScrapeData: scrapeData, - WaitTime: 8 * time.Second, - Validator: func(t *testing.T, req *CapturedRequest) { - may(t, req != nil, "At least one request should be sent") - t.Logf("Request received (parallel sending is optional)") - }, - }) - }) } diff --git a/remotewrite/sender/combined_test.go b/remotewrite/sender/combined_test.go index 57ec78a3..ab4cc37a 100644 --- a/remotewrite/sender/combined_test.go +++ b/remotewrite/sender/combined_test.go @@ -14,283 +14,19 @@ package sender import ( - "strings" "testing" - writev2 "github.com/prometheus/prometheus/prompb/io/prometheus/write/v2" + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" ) -// TestCombinedFeatures validates integration of multiple Remote Write 2.0 features. -func TestCombinedFeatures_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []TestCase{ - { - Name: "samples_with_metadata", - Description: "Sender SHOULD send samples with associated metadata", - RFCLevel: "SHOULD", - ScrapeData: `# HELP http_requests_total Total HTTP requests received -# TYPE http_requests_total counter -http_requests_total{method="GET",status="200"} 1000 -http_requests_total{method="POST",status="201"} 500 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetric bool - var foundWithMetadata bool - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "http_requests_total" { - foundMetric = true - should(t, len(ts.Samples) > 0, "Counter should have samples") - - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - should(t, ts.Metadata.Type == writev2.Metadata_METRIC_TYPE_COUNTER, - "Metadata type should match metric type") - foundWithMetadata = true - } - - if ts.Metadata.HelpRef != 0 { - helpText := req.Request.Symbols[ts.Metadata.HelpRef] - should(t, strings.Contains(helpText, "HTTP requests"), - "Help text should be meaningful") - } - } - } - - if !foundMetric { - t.Fatalf("Expected to find http_requests_total metric") - } - - should(t, foundWithMetadata, "Metadata should be present with samples") - }, - }, - { - Name: "samples_with_exemplars", - Description: "Sender MAY send samples with attached exemplars", - RFCLevel: "MAY", - ScrapeData: `# TYPE request_count counter -request_count 1000 # {trace_id="abc123",span_id="def456"} 999 1234567890.123 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetric bool - var foundExemplar bool - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "request_count" { - foundMetric = true - if len(ts.Exemplars) > 0 { - foundExemplar = true - ex := ts.Exemplars[0] - exLabels := extractExemplarLabels(&ex, req.Request.Symbols) - t.Logf("Found exemplar with labels: %v", exLabels) - } - } - } - - if !foundMetric { - t.Fatalf("Expected to find request_count metric") - } - - may(t, foundExemplar, "Exemplars present") - }, - }, - { - Name: "histogram_with_metadata_and_exemplars", - Description: "Sender MAY send histograms with metadata and exemplars", - RFCLevel: "MAY", - ScrapeData: `# HELP request_duration_seconds Request duration in seconds -# TYPE request_duration_seconds histogram -request_duration_seconds_bucket{le="0.1"} 100 # {trace_id="hist123"} 0.05 1234567890.0 -request_duration_seconds_bucket{le="0.5"} 250 -request_duration_seconds_bucket{le="1.0"} 500 -request_duration_seconds_bucket{le="+Inf"} 1000 -request_duration_seconds_sum 450.5 -request_duration_seconds_count 1000 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundHistogramData bool - var foundMetadata bool - var foundExemplar bool - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - - metricBase := "request_duration_seconds" - - if labels["__name__"] == metricBase+"_count" || - labels["__name__"] == metricBase+"_bucket" || - labels["__name__"] == metricBase { - foundHistogramData = true - - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - foundMetadata = true - } - - if len(ts.Exemplars) > 0 { - foundExemplar = true - } - } - } - - if !foundHistogramData { - t.Fatalf("Expected histogram data but none was found") - } - - may(t, foundMetadata, "Histogram metadata present") - may(t, foundExemplar, "Histogram exemplars present") - }, - }, - { - Name: "multiple_metric_types", - Description: "Sender MUST handle multiple metric types in same request", - RFCLevel: "MUST", - ScrapeData: `# TYPE process_cpu_seconds_total counter -process_cpu_seconds_total 123.45 - -# TYPE process_memory_bytes gauge -process_memory_bytes 1048576 - -# TYPE request_duration_seconds histogram -request_duration_seconds_bucket{le="+Inf"} 100 -request_duration_seconds_sum 50.0 -request_duration_seconds_count 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - metricTypes := make(map[string]bool) - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - - switch metricName { - case "process_cpu_seconds_total": - metricTypes["counter"] = true - case "process_memory_bytes": - metricTypes["gauge"] = true - case "request_duration_seconds_count", "request_duration_seconds": - metricTypes["histogram"] = true - } - } - - must(t).NotEmpty(metricTypes, "Request must contain metrics") - t.Logf("Found metric types: %v", metricTypes) - }, - }, - { - Name: "high_cardinality_labels", - Description: "Sender should efficiently handle high cardinality label sets", - RFCLevel: "RECOMMENDED", - ScrapeData: `# TYPE http_requests_total counter -http_requests_total{method="GET",path="/api/v1/users",status="200"} 100 -http_requests_total{method="GET",path="/api/v1/posts",status="200"} 200 -http_requests_total{method="POST",path="/api/v1/users",status="201"} 50 -http_requests_total{method="POST",path="/api/v1/posts",status="201"} 75 -http_requests_total{method="GET",path="/api/v1/comments",status="200"} 300 -http_requests_total{method="DELETE",path="/api/v1/users",status="204"} 10 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // With high cardinality, symbol table deduplication becomes important. - symbols := req.Request.Symbols - uniqueSymbols := make(map[string]bool) - - for _, sym := range symbols { - if sym != "" { - uniqueSymbols[sym] = true - } - } - - // Check that common strings are deduplicated. - recommended(t, len(uniqueSymbols) > 0, "Symbol table should contain unique symbols") - - httpRequestsSeries := 0 - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "http_requests_total" { - httpRequestsSeries++ - } - } - - recommended(t, httpRequestsSeries >= 6, - "High cardinality metrics should have multiple series") - t.Logf("Found %d unique symbols, %d http_requests_total series", - len(uniqueSymbols), httpRequestsSeries) - }, - }, - { - Name: "complete_metric_family", - Description: "Sender MUST send all components of metric family together", - RFCLevel: "MUST", - ScrapeData: `# HELP api_request_duration_seconds API request duration -# TYPE api_request_duration_seconds histogram -api_request_duration_seconds_bucket{le="0.1"} 50 -api_request_duration_seconds_bucket{le="0.5"} 150 -api_request_duration_seconds_bucket{le="1.0"} 250 -api_request_duration_seconds_bucket{le="+Inf"} 300 -api_request_duration_seconds_sum 200.5 -api_request_duration_seconds_count 300 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // For classic histograms, expect _sum, _count, and _bucket series. - var foundCount bool - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - - if metricName == "api_request_duration_seconds_count" { - foundCount = true - } else if metricName == "api_request_duration_seconds" && len(ts.Histograms) > 0 { - // Native histogram format has everything in one series. - foundCount = true - } - } - - // For classic histograms, all components should be present. - // For native histograms, they're combined. - must(t).True(foundCount || len(req.Request.Timeseries) > 0, - "Histogram family must include count") - }, - }, - { - Name: "mixed_labels_and_metadata", - Description: "Sender SHOULD correctly encode metrics with many labels and metadata", - RFCLevel: "SHOULD", - ScrapeData: `# HELP api_calls_total Total API calls with detailed labels -# TYPE api_calls_total counter -api_calls_total{service="auth",method="POST",endpoint="/login",region="us-east",status="200"} 1000 -api_calls_total{service="auth",method="POST",endpoint="/logout",region="us-east",status="200"} 500 -api_calls_total{service="users",method="GET",endpoint="/profile",region="eu-west",status="200"} 2000 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var seriesCount int - var metadataCount int - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "api_calls_total" { - seriesCount++ - - // Check labels are properly structured. - should(t, labels["service"] != "", "Service label should be present") - should(t, labels["method"] != "", "Method label should be present") - should(t, labels["endpoint"] != "", "Endpoint label should be present") - - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - metadataCount++ - } - } - } - - should(t, seriesCount >= 3, - "Should have multiple series with different label combinations") - }, - }, +func combinedTests() []Test { + return []Test{ { Name: "real_world_scenario", Description: "Sender MUST handle realistic mixed metric payload", - RFCLevel: "MUST", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, ScrapeData: `# Realistic scrape output with multiple metric types # TYPE up gauge up 1 @@ -314,29 +50,39 @@ http_request_duration_seconds_count 500 http_requests_total{method="GET",code="200"} 5000 http_requests_total{method="POST",code="201"} 1000 `, - Validator: func(t *testing.T, req *CapturedRequest) { - metricNames := make(map[string]bool) - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - metricNames[metricName] = true + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 1, "Expected at least 1 request") - // Validate each series has valid structure. - must(t).NotEmpty(metricName, "Each timeseries must have __name__") + metricNames := make(map[string]bool) - // Validate no mixed samples and histograms. - if len(ts.Samples) > 0 && len(ts.Histograms) > 0 { - must(t).Fail("Timeseries must not mix samples and histograms") + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + // Find the __name__ label + var metricName string + for i := 0; i < len(ts.LabelsRefs); i += 2 { + keyIdx := ts.LabelsRefs[i] + valIdx := ts.LabelsRefs[i+1] + if req.RW2.Symbols[keyIdx] == "__name__" { + metricName = req.RW2.Symbols[valIdx] + break + } + } + metricNames[metricName] = true + + require.NotEmpty(t, metricName, "Each timeseries must have __name__") + + // Validate no mixed samples and histograms. + if len(ts.Samples) > 0 && len(ts.Histograms) > 0 { + require.Fail(t, "Timeseries must not mix samples and histograms") + } + } } } - must(t).NotEmpty(metricNames, "Request must contain metrics") - must(t).GreaterOrEqual(len(metricNames), 3, - "Real-world scenario should have multiple distinct metrics") + require.NotEmpty(t, metricNames, "Request must contain metrics") + require.GreaterOrEqual(t, len(metricNames), 3, "Real-world scenario should have multiple distinct metrics") }, }, } - - runTestCases(t, tests) } diff --git a/remotewrite/sender/compliance.go b/remotewrite/sender/compliance.go index ab122d81..3e71cffa 100644 --- a/remotewrite/sender/compliance.go +++ b/remotewrite/sender/compliance.go @@ -39,6 +39,21 @@ import ( // ComplianceTests returns official compliance sender tests. func ComplianceTests() (ret []Test) { ret = append(ret, samplesTests()...) + ret = append(ret, batchingTests()...) + ret = append(ret, backoffTests()...) + ret = append(ret, combinedTests()...) + ret = append(ret, edgeCasesTests()...) + ret = append(ret, errorHandlingTests()...) + ret = append(ret, retryTests()...) + ret = append(ret, exemplarsTests()...) + ret = append(ret, histogramsTests()...) + ret = append(ret, labelsTests()...) + ret = append(ret, metadataTests()...) + ret = append(ret, protocolTests()...) + ret = append(ret, fallbackTests()...) + ret = append(ret, symbolsTests()...) + ret = append(ret, responseTests()...) + ret = append(ret, rw1CompatTests()...) return ret } diff --git a/remotewrite/sender/edge_cases_test.go b/remotewrite/sender/edge_cases_test.go index eb89ded7..9ca04039 100644 --- a/remotewrite/sender/edge_cases_test.go +++ b/remotewrite/sender/edge_cases_test.go @@ -15,383 +15,99 @@ package sender import ( "math" - "strings" "testing" - "time" -) -// TestEdgeCases validates sender behavior in edge case scenarios. -func TestEdgeCases_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []TestCase{ - { - Name: "empty_scrape", - Description: "Sender SHOULD handle scrapes with no metrics gracefully", - RFCLevel: "SHOULD", - ScrapeData: "# No metrics\n", - Validator: func(t *testing.T, req *CapturedRequest) { - // Empty scrape may result in no request, or empty request. - if req.Request != nil { - should(t, true, "Sender handled empty scrape") - t.Logf("Empty scrape handled: %d timeseries", len(req.Request.Timeseries)) - } else { - should(t, true, "Sender may skip empty scrapes") - t.Logf("No request sent for empty scrape (acceptable)") - } - }, - }, - { - Name: "huge_label_values", - Description: "Sender SHOULD handle very large label values (10KB+)", - RFCLevel: "SHOULD", - ScrapeData: func() string { - largeValue := strings.Repeat("x", 10000) - return `test_metric{large_label="` + largeValue + `"} 42` + "\n" - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - var foundLarge bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for _, value := range labels { - if len(value) > 5000 { - foundLarge = true - should(t, len(value) >= 5000, "Large label value should be preserved") - t.Logf("Found large label value: %d bytes", len(value)) - break - } - } - } - should(t, foundLarge || len(req.Request.Timeseries) == 0, "Large label values should be handled") - }, - }, +func edgeCasesTests() []Test { + return []Test{ { Name: "unicode_in_labels", Description: "Sender MUST preserve Unicode characters in labels", - RFCLevel: "MUST", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, ScrapeData: `test_metric{emoji="🚀",chinese="测试",arabic="مرحبا",vietnamese="tôi yêu việt nam"} 42` + "\n", - Validator: func(t *testing.T, req *CapturedRequest) { + Validate: func(t *testing.T, res ReceiverResult) { var foundUnicode bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - - // Check for Unicode in label values. - for key, value := range labels { - hasUnicode := false - for _, r := range value { - if r > 127 { - hasUnicode = true - break + for _, req := range res.Requests { + if req.RW2 != nil { + for _, sym := range req.RW2.Symbols { + hasUnicode := false + for _, r := range sym { + if r > 127 { + hasUnicode = true + break + } + } + if hasUnicode { + foundUnicode = true + require.NotEmpty(t, sym, "Unicode value must be preserved") } } - if hasUnicode { - foundUnicode = true - must(t).NotEmpty(value, "Unicode value must be preserved") - t.Logf("Unicode label %s=%s", key, value) - } - } - } - should(t, foundUnicode || len(req.Request.Timeseries) > 0, "Unicode characters should be preserved") - }, - }, - { - Name: "many_timeseries", - Description: "Sender should efficiently handle many timeseries", - RFCLevel: "RECOMMENDED", - ScrapeData: func() string { - var sb strings.Builder - for i := 0; i < 100; i++ { - sb.WriteString("metric_") - sb.WriteString(strings.Repeat("0", 3-len(string(rune(i/100))))) - sb.WriteString(string(rune(48 + i))) - sb.WriteString(" ") - sb.WriteString(string(rune(48 + i))) - sb.WriteString("\n") - } - return sb.String() - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - seriesCount := len(req.Request.Timeseries) - recommended(t, seriesCount >= 10, "Should handle multiple timeseries efficiently") - - // Check symbol table efficiency. - symbols := req.Request.Symbols - recommended(t, len(symbols) > 0, "Symbol table should be used") - - t.Logf("Handled %d timeseries with %d symbols", - seriesCount, len(symbols)) - }, - }, - { - Name: "high_cardinality", - Description: "Sender should handle high cardinality label sets efficiently", - RFCLevel: "RECOMMENDED", - ScrapeData: func() string { - var sb strings.Builder - // Create high cardinality by varying one label across many values. - for i := 0; i < 50; i++ { - sb.WriteString("http_requests{path=\"/api/v1/endpoint_") - sb.WriteString(string(rune(48 + i/10))) - sb.WriteString(string(rune(48 + i%10))) - sb.WriteString("\",method=\"GET\"} ") - sb.WriteString(string(rune(48 + i))) - sb.WriteString("\n") - } - return sb.String() - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - seriesCount := len(req.Request.Timeseries) - recommended(t, seriesCount >= 20, "Should handle high cardinality metrics") - - // Symbol table should deduplicate common strings. - symbols := req.Request.Symbols - uniqueSymbols := make(map[string]bool) - for _, sym := range symbols { - if sym != "" { - uniqueSymbols[sym] = true } } - - recommended(t, len(uniqueSymbols) > 0, "Symbol table should deduplicate in high cardinality") - t.Logf("High cardinality: %d series, %d unique symbols", - seriesCount, len(uniqueSymbols)) - }, - }, - { - Name: "very_long_metric_name", - Description: "Sender SHOULD handle very long metric names", - RFCLevel: "SHOULD", - ScrapeData: func() string { - longName := "metric_" + strings.Repeat("very_long_name_", 50) - return longName + " 42\n" - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - var foundLongName bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - if len(metricName) > 100 { - foundLongName = true - should(t, len(metricName) > 0, "Long metric name should be preserved") - t.Logf("Long metric name: %d chars", len(metricName)) - break - } - } - should(t, foundLongName || len(req.Request.Timeseries) > 0, "Long metric names should be handled") + require.True(t, foundUnicode, "Unicode characters should be preserved in symbols") }, }, { Name: "special_float_combinations", Description: "Sender MUST handle special float value combinations", - RFCLevel: "MUST", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, ScrapeData: `special_values{type="nan"} NaN special_values{type="inf"} +Inf special_values{type="ninf"} -Inf special_values{type="zero"} 0 special_values{type="negative"} -123.45 `, - Validator: func(t *testing.T, req *CapturedRequest) { + Validate: func(t *testing.T, res ReceiverResult) { foundSpecial := make(map[string]bool) - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "special_values" && len(ts.Samples) > 0 { - value := ts.Samples[0].Value - valueType := labels["type"] - - switch valueType { - case "nan": - if math.IsNaN(value) { - foundSpecial["nan"] = true - } - case "inf": - if math.IsInf(value, 1) { - foundSpecial["inf"] = true - } - case "ninf": - if math.IsInf(value, -1) { - foundSpecial["ninf"] = true + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + if len(ts.Samples) > 0 { + // find value of type label + var valueType string + for i := 0; i < len(ts.LabelsRefs); i += 2 { + if req.RW2.Symbols[ts.LabelsRefs[i]] == "type" { + valueType = req.RW2.Symbols[ts.LabelsRefs[i+1]] + break + } + } + + value := ts.Samples[0].Value + switch valueType { + case "nan": + if math.IsNaN(value) { + foundSpecial["nan"] = true + } + case "inf": + if math.IsInf(value, 1) { + foundSpecial["inf"] = true + } + case "ninf": + if math.IsInf(value, -1) { + foundSpecial["ninf"] = true + } + case "zero": + if value == 0 { + foundSpecial["zero"] = true + } + case "negative": + if value < 0 { + foundSpecial["negative"] = true + } + } } - case "zero": - if value == 0 { - foundSpecial["zero"] = true - } - case "negative": - if value < 0 { - foundSpecial["negative"] = true - } - } - } - } - - must(t).GreaterOrEqual(len(foundSpecial), 1, - "Should handle special float values") - t.Logf("Special values handled: %v", foundSpecial) - }, - }, - { - Name: "zero_timestamp", - Description: "Sender SHOULD handle timestamp value of 0", - RFCLevel: "SHOULD", - ScrapeData: "test_metric 42 0\n", - Validator: func(t *testing.T, req *CapturedRequest) { - // Timestamp of 0 might be rejected or normalized, sender should handle gracefully. - for _, ts := range req.Request.Timeseries { - if len(ts.Samples) > 0 { - timestamp := ts.Samples[0].Timestamp - should(t, timestamp >= int64(0), "Timestamp should be non-negative") - t.Logf("Timestamp handling: %d", timestamp) - } - } - }, - }, - { - Name: "future_timestamp", - Description: "Sender SHOULD handle timestamps in the future", - RFCLevel: "SHOULD", - ScrapeData: func() string { - future := time.Now().Add(24 * time.Hour).Unix() - return "test_metric 42 " + string(rune(future)) + "\n" - }(), - Validator: func(t *testing.T, req *CapturedRequest) { - now := time.Now().UnixMilli() - - for _, ts := range req.Request.Timeseries { - if len(ts.Samples) > 0 { - timestamp := ts.Samples[0].Timestamp - - // Check if timestamp is in future. - if timestamp > now { - diff := timestamp - now - should(t, diff >= int64(0), "Future timestamp should be handled") - t.Logf("Future timestamp: %d ms ahead", diff) } } } - }, - }, - { - Name: "metric_name_with_colons", - Description: "Sender MUST handle metric names with colons", - RFCLevel: "MUST", - ScrapeData: "http:request:duration:seconds 0.5\n", - Validator: func(t *testing.T, req *CapturedRequest) { - var foundColon bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - if strings.Contains(metricName, ":") { - foundColon = true - must(t).Contains(metricName, ":", - "Metric name with colons must be preserved") - t.Logf("Metric name with colons: %s", metricName) - break - } - } - must(t).True(foundColon || len(req.Request.Timeseries) > 0, - "Metric names with colons are valid and must be handled") - }, - }, - { - Name: "stale_marker", - Description: "Sender SHOULD handle stale marker (StaleNaN)", - RFCLevel: "SHOULD", - ScrapeData: "test_metric StaleNaN\n", - Validator: func(t *testing.T, req *CapturedRequest) { - // StaleNaN is a special NaN value. - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "test_metric" && len(ts.Samples) > 0 { - value := ts.Samples[0].Value - should(t, math.IsNaN(value), "StaleNaN should be encoded as NaN") - t.Logf("Stale marker handled: NaN=%v", math.IsNaN(value)) - } - } - }, - }, - { - Name: "mixed_sample_and_histogram_families", - Description: "Sender MUST handle different metric types in same payload", - RFCLevel: "MUST", - ScrapeData: `# Counter -requests_total 100 - -# Gauge -temperature_celsius 22.5 - -# Histogram -response_time_bucket{le="0.1"} 50 -response_time_bucket{le="+Inf"} 100 -response_time_sum 10.5 -response_time_count 100 - -# Summary -rpc_duration{quantile="0.5"} 0.05 -rpc_duration{quantile="0.9"} 0.1 -rpc_duration_sum 50.0 -rpc_duration_count 1000 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - metricTypes := make(map[string]bool) - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - name := labels["__name__"] - - if name == "requests_total" { - metricTypes["counter"] = true - } else if name == "temperature_celsius" { - metricTypes["gauge"] = true - } else if strings.HasPrefix(name, "response_time") { - metricTypes["histogram"] = true - } else if strings.HasPrefix(name, "rpc_duration") { - metricTypes["summary"] = true - } - } - must(t).GreaterOrEqual(len(metricTypes), 2, - "Must handle multiple metric types in same payload") - t.Logf("Metric types found: %v", metricTypes) + require.GreaterOrEqual(t, len(foundSpecial), 1, "Should handle special float values") }, }, } - - runTestCases(t, tests) -} - -// TestRobustnessUnderLoad validates sender behavior under stress. -func TestRobustnessUnderLoad_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "SHOULD") - t.Attr("description", "Sender SHOULD remain stable under load") - - // Generate larger scrape data. - var scrapeData strings.Builder - for i := 0; i < 200; i++ { - scrapeData.WriteString("load_test_metric_") - scrapeData.WriteString(string(rune(48 + i/100))) - scrapeData.WriteString(string(rune(48 + (i/10)%10))) - scrapeData.WriteString(string(rune(48 + i%10))) - scrapeData.WriteString("{label=\"value_") - scrapeData.WriteString(string(rune(48 + i%10))) - scrapeData.WriteString("\"} ") - scrapeData.WriteString(string(rune(48 + i%10))) - scrapeData.WriteString("\n") - } - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - runSenderTest(t, targetName, target, SenderTestScenario{ - ScrapeData: scrapeData.String(), - Validator: func(t *testing.T, req *CapturedRequest) { - should(t, len(req.Request.Timeseries) > 0, "Should handle load test data") - - seriesCount := len(req.Request.Timeseries) - should(t, seriesCount >= 50, "Should batch substantial amount of data") - - t.Logf("Load test: %d timeseries sent", seriesCount) - }, - WaitTime: 8 * time.Second, - }) - }) } diff --git a/remotewrite/sender/error_handling_test.go b/remotewrite/sender/error_handling_test.go index 365ee2bd..63476d63 100644 --- a/remotewrite/sender/error_handling_test.go +++ b/remotewrite/sender/error_handling_test.go @@ -15,221 +15,28 @@ package sender import ( "net/http" - "net/http/httptest" "testing" - "time" -) -// TestErrorHandling validates sender error handling in various failure scenarios. -func TestErrorHandling_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []struct { - name string - description string - rfcLevel string - scrapeData string - setup func() *httptest.Server - validator func(*testing.T, *httptest.Server) - }{ +func errorHandlingTests() []Test { + return []Test{ { - name: "handle_connection_refused", - description: "Sender SHOULD handle connection refused gracefully", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - // Return a server that immediately closes - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - })) - server.Close() - return server + Name: "sender_continues_after_errors", + Description: "Sender MUST continue running after recoverable errors", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusInternalServerError, Body: "Internal server error"}, + {StatusCode: http.StatusNoContent}, }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle connection refused") - t.Logf("Sender handled connection refused scenario") + Validate: func(t *testing.T, res ReceiverResult) { + // Sender should have successfully retried and continued. + require.GreaterOrEqual(t, len(res.Requests), 2, "Sender must continue running after errors and retry") }, }, - { - name: "handle_timeout", - description: "Sender SHOULD handle request timeouts gracefully", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(30 * time.Second) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle timeouts") - t.Logf("Sender handled timeout scenario") - }, - }, - { - name: "handle_partial_write", - description: "Sender SHOULD handle partial HTTP writes gracefully", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("partial")) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle partial writes") - t.Logf("Sender handled partial write scenario") - }, - }, - { - name: "handle_malformed_response", - description: "Sender SHOULD handle malformed HTTP responses", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Not valid response headers\r\n")) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle malformed responses") - t.Logf("Sender handled malformed response") - }, - }, - { - name: "sender_continues_after_errors", - description: "Sender MUST continue running after recoverable errors", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Return error but sender should keep trying. - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - // Sender should not crash and keep running. - must(t).True(true, "Sender must continue running after errors") - t.Logf("Sender continues running after errors") - }, - }, - { - name: "handle_invalid_status_code", - description: "Sender SHOULD handle invalid HTTP status codes", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(999) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle unusual status codes") - t.Logf("Sender handled invalid status code") - }, - }, - { - name: "handle_empty_response", - description: "Sender SHOULD handle empty responses gracefully", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle empty responses") - t.Logf("Sender handled empty response") - }, - }, - { - name: "handle_large_error_response", - description: "Sender SHOULD handle large error response bodies", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - // Send large error message - largeError := make([]byte, 1024*1024) // 1MB - for i := range largeError { - largeError[i] = 'x' - } - w.Write(largeError) - })) - }, - validator: func(t *testing.T, server *httptest.Server) { - should(t, true, "Sender should handle large error bodies") - t.Logf("Sender handled large error response") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - t.Attr("rfcLevel", tt.rfcLevel) - t.Attr("description", tt.description) - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - server := tt.setup() - var serverURL string - if server != nil { - defer server.Close() - serverURL = server.URL - } else { - serverURL = "http://localhost:19999" - } - - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - runAutoTargetWithCustomReceiver(t, targetName, target, serverURL, scrapeTarget, 8*time.Second) - - tt.validator(t, server) - }) - }) - } -} - -// TestNetworkErrors validates handling of network-level errors. -func TestNetworkErrors_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "SHOULD") - t.Attr("description", "Sender SHOULD handle network errors gracefully") - - tests := []struct { - name string - scrapeData string - serverURL string - }{ - { - name: "dns_resolution_failure", - scrapeData: "test_metric 42\n", - serverURL: "http://nonexistent.invalid.domain:9999/api/v1/write", - }, - { - name: "invalid_url_scheme", - scrapeData: "test_metric 42\n", - serverURL: "invalid://localhost:9999", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - runAutoTargetWithCustomReceiver(t, targetName, target, tt.serverURL, scrapeTarget, 5*time.Second) - - should(t, true, "Sender handled network error without crashing") - t.Logf("Network error handled gracefully: %s", tt.name) - }) - }) } } diff --git a/remotewrite/sender/exemplars_test.go b/remotewrite/sender/exemplars_test.go index add33f76..65c04f51 100644 --- a/remotewrite/sender/exemplars_test.go +++ b/remotewrite/sender/exemplars_test.go @@ -14,215 +14,47 @@ package sender import ( - "fmt" "testing" -) -// TestExemplarEncoding validates exemplar encoding in Remote Write 2.0. -func TestExemplarEncoding_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []TestCase{ +func exemplarsTests() []Test { + return []Test{ { - Name: "exemplar_with_trace_id", - Description: "Sender MAY attach exemplars with trace_id to samples", - RFCLevel: "MAY", - ScrapeData: `# TYPE http_request_duration_seconds histogram -http_request_duration_seconds_bucket{le="0.1"} 50 # {trace_id="abc123xyz"} 0.05 1234567890.123 -http_request_duration_seconds_bucket{le="+Inf"} 100 -http_request_duration_seconds_sum 10.5 -http_request_duration_seconds_count 100 + Name: "exemplar_fields_valid", + Description: "Sender MUST send valid exemplars if supported", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: `# TYPE request_count counter +request_count 1000 # {trace_id="abc123"} 999 1234567890.123 `, - Validator: func(t *testing.T, req *CapturedRequest) { + Validate: func(t *testing.T, res ReceiverResult) { var foundExemplar bool - for _, ts := range req.Request.Timeseries { - if len(ts.Exemplars) > 0 { - foundExemplar = true - // Check if trace_id label is present. - ex := ts.Exemplars[0] - exLabels := extractExemplarLabels(&ex, req.Request.Symbols) - may(t, exLabels["trace_id"] != "", "Exemplar may include trace_id label") - t.Logf("Found exemplar with labels: %v", exLabels) - break - } - } - may(t, foundExemplar || len(req.Request.Timeseries) > 0, "Exemplars may be present if supported by sender") - }, - }, - { - Name: "exemplar_with_span_id", - Description: "Sender MAY attach exemplars with span_id to samples", - RFCLevel: "MAY", - ScrapeData: `# TYPE http_requests_total counter -http_requests_total 1000 # {trace_id="abc123",span_id="def456"} 999 1234567890.5 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundSpanId bool - for _, ts := range req.Request.Timeseries { - if len(ts.Exemplars) > 0 { - for _, ex := range ts.Exemplars { - exLabels := extractExemplarLabels(&ex, req.Request.Symbols) - if _, ok := exLabels["span_id"]; ok { - foundSpanId = true - may(t, len(exLabels["span_id"]) > 0, "Exemplar may include span_id label") - break - } - } - } - } - may(t, foundSpanId || len(req.Request.Timeseries) > 0, "Exemplar may include span_id if supported") - }, - }, - { - Name: "exemplar_value_valid", - Description: "Exemplar MUST have valid float value if present", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_counter counter -test_counter 100 # {trace_id="test123"} 99.5 1234567890.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - for _, ex := range ts.Exemplars { - must(t).NotNil(ex.Value, "Exemplar value must be set") - t.Logf("Exemplar value: %f", ex.Value) - } - } - }, - }, - { - Name: "exemplar_timestamp_valid", - Description: "Exemplar MUST have valid timestamp if present", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_counter counter -test_counter 100 # {trace_id="test123"} 99 1234567890.123 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - for _, ex := range ts.Exemplars { - must(t).Greater(ex.Timestamp, int64(0), - "Exemplar timestamp must be positive") - must(t).Greater(ex.Timestamp, int64(1e12), - "Exemplar timestamp should be in milliseconds") - } - } - }, - }, - { - Name: "exemplar_labels_valid_refs", - Description: "Exemplar label refs MUST point to valid symbol table indices", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_metric counter -test_metric 100 # {trace_id="xyz"} 99 1234567890.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - for _, ts := range req.Request.Timeseries { - for _, ex := range ts.Exemplars { - // Validate all label refs. - for i, ref := range ex.LabelsRefs { - must(t).Less(int(ref), len(symbols), - "Exemplar label ref[%d] = %d must be valid symbol index (table size: %d)", - i, ref, len(symbols)) - } - } - } - }, - }, - { - Name: "exemplar_custom_labels", - Description: "Sender MAY attach exemplars with custom labels beyond trace/span", - RFCLevel: "MAY", - ScrapeData: `# TYPE test_counter counter -test_counter 50 # {user_id="user123",request_id="req456"} 49 1234567890.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundCustom bool - for _, ts := range req.Request.Timeseries { - for _, ex := range ts.Exemplars { - exLabels := extractExemplarLabels(&ex, req.Request.Symbols) - // Check for non-standard exemplar labels. - for key := range exLabels { - if key != "trace_id" && key != "span_id" { - foundCustom = true - may(t, exLabels[key] != "", fmt.Sprintf("Custom exemplar labels may be used: %s", key)) - break + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + for _, ex := range ts.Exemplars { + foundExemplar = true + require.NotZero(t, ex.Timestamp, "Exemplar timestamp must be set") + require.Equal(t, 0, len(ex.LabelsRefs)%2, "Exemplar labels must be even length") + // Ensure references are valid + for _, ref := range ex.LabelsRefs { + require.Less(t, int(ref), len(req.RW2.Symbols), "Exemplar label ref out of bounds") + } } } } } - may(t, foundCustom || len(req.Request.Timeseries) > 0, "Custom exemplar labels may be present") - }, - }, - { - Name: "exemplar_on_histogram", - Description: "Sender MAY attach exemplars to histogram buckets", - RFCLevel: "MAY", - ScrapeData: `# TYPE request_duration histogram -request_duration_bucket{le="0.1"} 10 # {trace_id="hist123"} 0.05 1234567890.0 -request_duration_bucket{le="+Inf"} 100 -request_duration_sum 50.0 -request_duration_count 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundHistogramExemplar bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - - // Check for histogram-related timeseries with exemplars. - if (labels["__name__"] == "request_duration_bucket" || - labels["__name__"] == "request_duration") && - len(ts.Exemplars) > 0 { - foundHistogramExemplar = true - may(t, len(ts.Exemplars) > 0, "Exemplars may be attached to histogram buckets") - break - } + // It's a MAY for senders to support exemplars, but if they send them, they must be valid. + // We won't require foundExemplar to be true if they don't support it, but since they are + // scraping the data, we hope to see it. Wait, the test is MUST if sent. + // For the sake of validation, let's just log if not found. + if !foundExemplar { + t.Log("Sender did not send exemplars (optional)") } - may(t, foundHistogramExemplar || len(req.Request.Timeseries) > 0, "Histogram exemplars may be present") - }, - }, - { - Name: "exemplar_labels_even_length", - Description: "Exemplar label refs array MUST have even length (key-value pairs)", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_counter counter -test_counter 100 # {trace_id="test"} 99 1234567890.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - for _, ex := range ts.Exemplars { - refsLen := len(ex.LabelsRefs) - must(t).Equal(0, refsLen%2, - "Exemplar label refs length must be even (key-value pairs), got: %d", - refsLen) - } - } - }, - }, - { - Name: "multiple_exemplars_per_series", - Description: "Sender MAY attach multiple exemplars to a single timeseries", - RFCLevel: "MAY", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_bucket{le="0.1"} 10 # {trace_id="ex1"} 0.05 1234567890.0 -test_histogram_bucket{le="0.5"} 50 # {trace_id="ex2"} 0.3 1234567891.0 -test_histogram_bucket{le="+Inf"} 100 -test_histogram_sum 50.0 -test_histogram_count 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMultiple bool - for _, ts := range req.Request.Timeseries { - if len(ts.Exemplars) > 1 { - foundMultiple = true - may(t, len(ts.Exemplars) > 1, "Multiple exemplars may be attached to a timeseries") - t.Logf("Found %d exemplars in timeseries", len(ts.Exemplars)) - break - } - } - may(t, foundMultiple || len(req.Request.Timeseries) > 0, "Multiple exemplars may be present") }, }, } - - runTestCases(t, tests) } diff --git a/remotewrite/sender/fallback_test.go b/remotewrite/sender/fallback_test.go index e4503fc5..f0b1cab0 100644 --- a/remotewrite/sender/fallback_test.go +++ b/remotewrite/sender/fallback_test.go @@ -14,316 +14,30 @@ package sender import ( - "fmt" - "net/http" - "strings" - "sync" "testing" - "time" -) - -// FallbackTrackingReceiver tracks version changes across requests. -type FallbackTrackingReceiver struct { - *MockReceiver - mu sync.Mutex - requestVersions []string - requestCount int - return415First bool -} - -// NewFallbackTrackingReceiver creates a receiver that tracks version fallback. -func NewFallbackTrackingReceiver(baseReceiver *MockReceiver, return415First bool) *FallbackTrackingReceiver { - return &FallbackTrackingReceiver{ - MockReceiver: baseReceiver, - requestVersions: make([]string, 0), - return415First: return415First, - } -} - -// RecordVersion records the version from a request. -func (ftr *FallbackTrackingReceiver) RecordVersion(version string) { - ftr.mu.Lock() - defer ftr.mu.Unlock() - ftr.requestVersions = append(ftr.requestVersions, version) - ftr.requestCount++ -} - -// GetVersions returns all recorded versions. -func (ftr *FallbackTrackingReceiver) GetVersions() []string { - ftr.mu.Lock() - defer ftr.mu.Unlock() - return append([]string{}, ftr.requestVersions...) -} - -// ShouldReturn415 determines if this request should get 415 response. -func (ftr *FallbackTrackingReceiver) ShouldReturn415() bool { - ftr.mu.Lock() - defer ftr.mu.Unlock() - // Only return 415 for the first request - return ftr.return415First && ftr.requestCount == 0 -} - -// TestFallbackBehavior validates RW 2.0 to RW 1.0 fallback on 415 response. -func TestFallbackBehavior_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []struct { - name string - description string - rfcLevel string - scrapeData string - validator func(*testing.T, *FallbackTrackingReceiver) - }{ - { - name: "fallback_on_415_unsupported_media_type", - description: "Sender SHOULD fallback to RW 1.0 when receiving 415 Unsupported Media Type", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - validator: func(t *testing.T, tracker *FallbackTrackingReceiver) { - time.Sleep(12 * time.Second) - - versions := tracker.GetVersions() - if len(versions) < 2 { - should(t, len(versions) >= 2, "Should see multiple requests for fallback validation") - t.Logf("Only %d request(s) observed, cannot validate fallback", len(versions)) - return - } - - // Check if version changed from 2.0 to earlier version. - firstVersion := versions[0] - laterVersions := versions[1:] - - if strings.HasPrefix(firstVersion, "2.0") { - // First request was RW 2.0, check if later requests fell back. - for i, v := range laterVersions { - if strings.HasPrefix(v, "0.1") || v == "" { - should(t, true, "Sender fell back from RW 2.0 to RW 1.0 after 415") - t.Logf("Fallback detected: %s -> %s (request %d)", - firstVersion, v, i+2) - return - } - } - t.Logf("No fallback detected in %d requests (versions: %v)", - len(versions), versions) - } else { - t.Logf("First request not RW 2.0 (version: %s), fallback test not applicable", - firstVersion) - } - }, - }, - { - name: "retry_with_different_version", - description: "Sender SHOULD retry with RW 1.0 version (0.1.0) after 415", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - validator: func(t *testing.T, tracker *FallbackTrackingReceiver) { - time.Sleep(12 * time.Second) - - versions := tracker.GetVersions() - if len(versions) < 2 { - t.Logf("Need multiple requests to validate version change") - return - } - - // Look for version change pattern. - var foundFallback bool - for i := 1; i < len(versions); i++ { - if strings.HasPrefix(versions[i-1], "2.0") && - (strings.HasPrefix(versions[i], "0.1") || versions[i] == "") { - foundFallback = true - should(t, true, fmt.Sprintf("Version changed from %s to %s", versions[i-1], versions[i])) - break - } - } - - if !foundFallback { - t.Logf("No version fallback observed (versions: %v)", versions) - } - }, - }, - { - name: "fallback_header_changes", - description: "Sender MUST change Content-Type header on fallback", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - validator: func(t *testing.T, tracker *FallbackTrackingReceiver) { - time.Sleep(10 * time.Second) - - requests := tracker.GetRequests() - if len(requests) < 2 { - t.Logf("Need multiple requests to validate header changes") - return - } - - // Check if Content-Type changed between requests. - firstCT := requests[0].Headers.Get("Content-Type") - for i := 1; i < len(requests); i++ { - laterCT := requests[i].Headers.Get("Content-Type") - // If fallback happened, content-type should differ. - if firstCT != laterCT { - must(t).NotEqual(firstCT, laterCT, - "Content-Type should change on fallback") - t.Logf("Content-Type changed: %s -> %s", firstCT, laterCT) - return - } - } + "github.com/prometheus/client_golang/exp/api/remote" +) - t.Logf("No Content-Type change observed") - }, - }, +func fallbackTests() []Test { + return []Test{ { - name: "accept_success_after_fallback", - description: "Sender SHOULD accept successful response after fallback", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - validator: func(t *testing.T, tracker *FallbackTrackingReceiver) { - time.Sleep(10 * time.Second) - - // After fallback, subsequent requests should succeed. - requests := tracker.GetRequests() - should(t, len(requests) >= 1, "Should receive requests after fallback") - - t.Logf("Received %d requests total", len(requests)) + Name: "fallback_on_415", + Description: "Sender MAY fall back on 415", + RFCLevel: MayLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: 415}, + {StatusCode: 204}, }, - }, - { - name: "persistent_fallback_choice", - description: "Sender SHOULD remember successful fallback choice", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - validator: func(t *testing.T, tracker *FallbackTrackingReceiver) { - time.Sleep(15 * time.Second) - - versions := tracker.GetVersions() - if len(versions) < 3 { - t.Logf("Need multiple requests to validate persistent fallback") - return - } - - // After fallback, version should stay consistent. - var fallbackVersion string - for i := 1; i < len(versions); i++ { - if versions[i] != versions[0] { - fallbackVersion = versions[i] - break - } - } - - if fallbackVersion != "" { - // Check that subsequent requests use the same version. - consistentFallback := true - for i := 2; i < len(versions); i++ { - if versions[i] != fallbackVersion && versions[i] != versions[0] { - consistentFallback = false - break - } - } - - should(t, consistentFallback, "Sender should consistently use fallback version") - t.Logf("Fallback persistence: versions=%v", versions) + Validate: func(t *testing.T, res ReceiverResult) { + if len(res.Requests) > 1 { + t.Log("Sender attempted fallback or retry on 415") + } else { + t.Log("Sender did not fallback on 415 (optional)") } }, }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - t.Attr("rfcLevel", tt.rfcLevel) - t.Attr("description", tt.description) - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - tracker := NewFallbackTrackingReceiver(receiver, true) - - // Setup custom handler that returns 415 first, then 204. - originalHandler := receiver.server.Config.Handler - receiver.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - version := r.Header.Get("X-Prometheus-Remote-Write-Version") - contentType := r.Header.Get("Content-Type") - - tracker.RecordVersion(version) - t.Logf("Request %d: version=%s, content-type=%s", - len(tracker.GetVersions()), version, contentType) - - if tracker.ShouldReturn415() { - // Return 415 for first RW 2.0 request. - if strings.HasPrefix(version, "2.0") { - t.Logf("Returning 415 to trigger fallback") - w.WriteHeader(http.StatusUnsupportedMediaType) - w.Write([]byte("RW 2.0 not supported, please use RW 1.0")) - return - } - } - - // For subsequent requests or RW 1.0, return success. - originalHandler.ServeHTTP(w, r) - }) - - // Set successful response for non-415 cases. - receiver.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - runAutoTargetWithCustomReceiver(t, targetName, target, receiver.URL(), scrapeTarget, 15*time.Second) - tt.validator(t, tracker) - }) - }) - } -} - -// TestNoFallbackOn2xx validates that fallback doesn't happen on success. -func TestNoFallbackOn2xx_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "MUST") - t.Attr("description", "Sender MUST NOT fallback when receiving 2xx success responses") - - scrapeData := "test_metric 42\n" - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - tracker := NewFallbackTrackingReceiver(receiver, false) - - originalHandler := receiver.server.Config.Handler - receiver.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - version := r.Header.Get("X-Prometheus-Remote-Write-Version") - tracker.RecordVersion(version) - originalHandler.ServeHTTP(w, r) - }) - - receiver.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - - scrapeTarget := NewMockScrapeTarget(scrapeData) - defer scrapeTarget.Close() - - runAutoTargetWithCustomReceiver(t, targetName, target, receiver.URL(), scrapeTarget, 12*time.Second) - - versions := tracker.GetVersions() - if len(versions) > 0 { - firstVersion := versions[0] - // All versions should be the same (no fallback on success). - for _, v := range versions { - must(t).Equal(firstVersion, v, - "Version should not change on successful responses") - } - t.Logf("No fallback on success: consistent version %s across %d requests", - firstVersion, len(versions)) - } - }) } diff --git a/remotewrite/sender/histograms_test.go b/remotewrite/sender/histograms_test.go index 982e8f06..b6d4de8b 100644 --- a/remotewrite/sender/histograms_test.go +++ b/remotewrite/sender/histograms_test.go @@ -15,269 +15,43 @@ package sender import ( "testing" -) -// TestHistogramEncoding validates native histogram encoding. -func TestHistogramEncoding_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []TestCase{ - { - Name: "native_histogram_structure", - Description: "Sender MUST correctly encode native histogram structure", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 10 -test_histogram_sum 25.5 -test_histogram_bucket{le="1"} 2 -test_histogram_bucket{le="5"} 7 -test_histogram_bucket{le="+Inf"} 10 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Note: This is a classic histogram, not native histogram. - // Native histograms use exponential buckets notation. - // For classic histograms, senders typically send as multiple timeseries. - classicFound, nativeTS := findHistogramData(req.Request, "test_histogram") - must(t).True(classicFound || nativeTS != nil, - "Histogram data must be present (either as count/sum/bucket or native format)") - }, - }, - { - Name: "histogram_count_present", - Description: "Sender MUST include histogram count", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 250.5 -test_histogram_bucket{le="+Inf"} 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - count, found := extractHistogramCount(req.Request, "test_histogram") - may(t, found, "Histogram count should be present in some form") - if found { - must(t).Equal(100.0, count, "Histogram count value must be correct") - } - }, - }, - { - Name: "histogram_sum_present", - Description: "Sender MUST include histogram sum", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 250.5 -test_histogram_bucket{le="+Inf"} 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - sum, found := extractHistogramSum(req.Request, "test_histogram") - may(t, found, "Histogram sum should be present in some form") - if found { - must(t).Equal(250.5, sum, "Histogram sum value must be correct") - } - }, - }, - { - Name: "histogram_buckets_ordered", - Description: "Sender SHOULD send histogram buckets in order", - RFCLevel: "SHOULD", - ScrapeData: `# TYPE request_duration histogram -request_duration_bucket{le="0.1"} 10 -request_duration_bucket{le="0.5"} 50 -request_duration_bucket{le="1.0"} 100 -request_duration_bucket{le="5.0"} 200 -request_duration_bucket{le="+Inf"} 250 -request_duration_sum 500.0 -request_duration_count 250 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Classic histograms are sent as separate timeseries, order is not guaranteed. - // Native histograms have internal bucket structure. +func histogramsTests() []Test { + return []Test{ + { + Name: "histogram_fields_valid", + Description: "Sender MUST send valid native histograms if supported", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: `# HELP request_duration_seconds Request duration in seconds +# TYPE request_duration_seconds histogram +request_duration_seconds_bucket{le="0.1"} 100 +request_duration_seconds_bucket{le="0.5"} 250 +request_duration_seconds_bucket{le="1.0"} 500 +request_duration_seconds_bucket{le="+Inf"} 1000 +request_duration_seconds_sum 450.5 +request_duration_seconds_count 1000 +`, + Validate: func(t *testing.T, res ReceiverResult) { var foundHistogram bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "request_duration" && len(ts.Histograms) > 0 { - foundHistogram = true - break + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + for _, hist := range ts.Histograms { + foundHistogram = true + require.NotZero(t, hist.Count, "Histogram must have count") + } + } } } - may(t, foundHistogram || len(req.Request.Timeseries) > 0, "Histogram data should be present") - }, - }, - { - Name: "histogram_positive_buckets", - Description: "Native histogram MAY include positive buckets", - RFCLevel: "MAY", - ScrapeData: `# TYPE test_native_histogram histogram -test_native_histogram_count 100 -test_native_histogram_sum 250.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Check if sender supports native histograms. - var foundNative bool - for _, ts := range req.Request.Timeseries { - if len(ts.Histograms) > 0 { - foundNative = true - hist := ts.Histograms[0] - may(t, len(hist.PositiveSpans) > 0, "Native histogram may have positive buckets") - break - } - } - may(t, foundNative || len(req.Request.Timeseries) > 0, "Histogram may be in native or classic format") - }, - }, - { - Name: "histogram_negative_buckets", - Description: "Native histogram MAY include negative buckets", - RFCLevel: "MAY", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 50 -test_histogram_sum -25.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Negative buckets are optional in native histograms. - var foundNative bool - for _, ts := range req.Request.Timeseries { - if len(ts.Histograms) > 0 { - foundNative = true - break - } - } - may(t, foundNative || len(req.Request.Timeseries) > 0, "Histogram may be in various formats") - }, - }, - { - Name: "histogram_zero_bucket", - Description: "Native histogram MAY include zero bucket", - RFCLevel: "MAY", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 10 -test_histogram_sum 0.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundNative bool - for _, ts := range req.Request.Timeseries { - if len(ts.Histograms) > 0 { - foundNative = true - break - } + if !foundHistogram { + t.Log("Sender did not send native histograms (or sent classic)") } - may(t, foundNative || len(req.Request.Timeseries) > 0, "Histogram data should be present in some form") - }, - }, - { - Name: "histogram_schema", - Description: "Native histogram MUST specify schema if using native format", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 500.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // If native histograms are present, they must have a schema. - for _, ts := range req.Request.Timeseries { - if len(ts.Histograms) > 0 { - hist := ts.Histograms[0] - must(t).NotNil(hist, "Native histogram must have schema") - t.Logf("Histogram schema: %d", hist.Schema) - break - } - } - }, - }, - { - Name: "histogram_timestamp", - Description: "Histogram MUST include valid timestamp", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 250.0 -test_histogram_bucket{le="+Inf"} 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundTimestamp bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - - if labels["__name__"] == "test_histogram_count" && len(ts.Samples) > 0 { - must(t).Greater(ts.Samples[0].Timestamp, int64(0), - "Histogram timestamp must be valid") - foundTimestamp = true - break - } - - if len(ts.Histograms) > 0 { - must(t).Greater(ts.Histograms[0].Timestamp, int64(0), - "Native histogram timestamp must be valid") - foundTimestamp = true - break - } - } - must(t).True(foundTimestamp, "Histogram must have valid timestamp") - }, - }, - { - Name: "histogram_no_mixed_with_samples", - Description: "Sender MUST NOT mix histogram and sample data in same timeseries", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 250.0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Check that no timeseries has both samples and histograms. - for _, ts := range req.Request.Timeseries { - if len(ts.Samples) > 0 && len(ts.Histograms) > 0 { - must(t).Fail("Timeseries must not contain both samples and histograms") - } - } - }, - }, - { - Name: "histogram_empty_buckets", - Description: "Sender SHOULD handle histograms with no observations", - RFCLevel: "SHOULD", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 0 -test_histogram_sum 0 -test_histogram_bucket{le="+Inf"} 0 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundEmpty bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "test_histogram_count" && len(ts.Samples) > 0 { - should(t, ts.Samples[0].Value == 0.0, "Empty histogram count should be 0") - foundEmpty = true - break - } - } - should(t, foundEmpty || len(req.Request.Timeseries) > 0, "Empty histogram should be handled correctly") - }, - }, - { - Name: "histogram_large_counts", - Description: "Sender MUST handle histograms with large observation counts", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 1000000000 -test_histogram_sum 5000000000.0 -test_histogram_bucket{le="+Inf"} 1000000000 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundLarge bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "test_histogram_count" && len(ts.Samples) > 0 { - must(t).Equal(1e9, ts.Samples[0].Value, - "Large histogram count must be correctly encoded") - foundLarge = true - break - } - } - may(t, foundLarge || len(req.Request.Timeseries) > 0, "Large histogram counts should be handled") }, }, } - - runTestCases(t, tests) } diff --git a/remotewrite/sender/labels_test.go b/remotewrite/sender/labels_test.go index 7c1de6f0..519ceee6 100644 --- a/remotewrite/sender/labels_test.go +++ b/remotewrite/sender/labels_test.go @@ -14,261 +14,44 @@ package sender import ( - "fmt" - "regexp" "testing" -) - -/* -TODO later -{ - Name: "job_instance_labels_present", - Description: "Sender SHOULD include job and instance labels in samples", - RFCLevel: ShouldLevel, - Validate: func(t *testing.T, res ReceiverResult) { - results := requireTimeseriesByMetricName(t, res.Requests[0].RW2, "test_float") - require.Len(t, results, 1, "Should receive exactly one timeseries for test_float") - labels := results[0].Labels - require.NotEmpty(t, labels["job"], "Sample should include 'job' label") - require.NotEmpty(t, labels["instance"], "Sample should include 'instance' label") - }, - }, -*/ - -// TestLabelValidation validates label encoding and formatting. -func TestLabelValidation_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []TestCase{ - { - Name: "label_lexicographic_ordering", - Description: "Labels MUST be sorted in lexicographic order", - RFCLevel: "MUST", - ScrapeData: `test_metric{aaa="1",bbb="2",zzz="3"} 42 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "test_metric" { - must(t).True(isSorted(req.Request.Symbols, ts.LabelsRefs), - "Labels must be sorted in lexicographic order") - break - } - } - }, - }, - { - Name: "metric_name_label_present", - Description: "Timeseries MUST include __name__ label", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - must(t).NotEmpty(req.Request.Timeseries, "Request must contain timeseries") - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - must(t).NotEmpty(labels["__name__"], - "Timeseries must include __name__ label") - } - }, - }, - { - Name: "metric_name_format_valid", - Description: "Metric name MUST match [a-zA-Z_:][a-zA-Z0-9_:]* regex", - RFCLevel: "MUST", - ScrapeData: "valid_metric_name:subsystem_total 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - metricNameRegex := regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`) - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - must(t).NotEmpty(metricName, "Metric name must not be empty") - must(t).True(metricNameRegex.MatchString(metricName), - "Metric name must match regex [a-zA-Z_:][a-zA-Z0-9_:]*, got: %s", metricName) - } - }, - }, - { - Name: "label_name_format_valid", - Description: "Label names MUST match [a-zA-Z_][a-zA-Z0-9_]* regex (except __name__)", - RFCLevel: "MUST", - ScrapeData: `test_metric{valid_label="value",another_1="val2"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - labelNameRegex := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for labelName := range labels { - if labelName == "" { - continue - } - must(t).True(labelNameRegex.MatchString(labelName) || labelName == "__name__", - "Label name must match regex [a-zA-Z_][a-zA-Z0-9_]*, got: %s", labelName) - } - } - }, - }, - { - Name: "no_duplicate_label_names", - Description: "Timeseries MUST NOT have duplicate label names", - RFCLevel: "MUST", - ScrapeData: `test_metric{foo="bar",baz="qux"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - expectedPairs := len(ts.LabelsRefs) / 2 - must(t).Equal(expectedPairs, len(labels), - "No duplicate label names allowed") - } - }, - }, - { - Name: "label_names_not_empty", - Description: "Label names MUST NOT be empty", - RFCLevel: "MUST", - ScrapeData: `test_metric{label="value"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - for _, ts := range req.Request.Timeseries { - refs := ts.LabelsRefs - for i := 0; i < len(refs); i += 2 { - keyRef := refs[i] - labelName := symbols[keyRef] - must(t).NotEmpty(labelName, "Label names must not be empty") - } - } - }, - }, - { - Name: "label_values_may_be_empty", - Description: "Label values MAY be empty strings", - RFCLevel: "MAY", - ScrapeData: `test_metric{empty=""} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - for _, ts := range req.Request.Timeseries { - refs := ts.LabelsRefs - for i := 1; i < len(refs); i += 2 { - valueRef := refs[i] - labelValue := symbols[valueRef] - may(t, len(labelValue) >= 0, "Label values may be empty") - } - } - }, - }, - { - Name: "reserved_label_prefix", - Description: "Labels with __ prefix SHOULD be reserved for internal use", - RFCLevel: "SHOULD", - ScrapeData: `test_metric{normal="value"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for labelName := range labels { - if labelName == "__name__" { - continue // __name__ is allowed - } - if len(labelName) >= 2 && labelName[0:2] == "__" { - should(t, labelName == "__name__", fmt.Sprintf("Labels with __ prefix should be reserved, found: %s", labelName)) - } - } - } - }, - }, - { - Name: "unicode_in_label_values", - Description: "Sender MUST handle Unicode characters in label values", - RFCLevel: "MUST", - ScrapeData: `test_metric{emoji="🚀",chinese="测试"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundUnicode bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for _, value := range labels { - // Check if value contains non-ASCII characters. - for _, r := range value { - if r > 127 { - foundUnicode = true - must(t).NotEmpty(value, "Unicode values must be preserved") - break +func labelsTests() []Test { + return []Test{ + { + Name: "labels_sorted_and_valid", + Description: "Sender MUST sort labels by name and have valid refs", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: `test_metric{b="2",a="1",c="3"} 42` + "\n", + Validate: func(t *testing.T, res ReceiverResult) { + var found bool + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + found = true + require.Equal(t, 0, len(ts.LabelsRefs)%2, "Labels must be even length") + var prevKey string + for i := 0; i < len(ts.LabelsRefs); i += 2 { + keyIdx := ts.LabelsRefs[i] + require.Less(t, int(keyIdx), len(req.RW2.Symbols), "Label key ref out of bounds") + key := req.RW2.Symbols[keyIdx] + // Note: __name__ is usually sorted first, but lexicographically it is before most things. + // We skip the check if i==0 just to initialize prevKey. + if i > 0 { + require.True(t, key > prevKey, "Labels must be sorted by key (got %s after %s)", key, prevKey) + } + prevKey = key } } } } - may(t, foundUnicode || len(req.Request.Timeseries) > 0, "Unicode characters may be present in labels") - }, - }, - { - Name: "special_chars_in_label_values", - Description: "Sender MUST handle special characters in label values", - RFCLevel: "MUST", - ScrapeData: `test_metric{path="/api/v1/users",query="foo=bar&baz=qux"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundSpecial bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for key, value := range labels { - if key == "path" || key == "query" { - must(t).NotEmpty(value, "Special characters must be preserved") - foundSpecial = true - } - } - } - should(t, foundSpecial || len(req.Request.Timeseries) > 0, "Special characters should be handled correctly") - }, - }, - { - Name: "very_long_label_names", - Description: "Sender SHOULD handle long label names (within reasonable limits)", - RFCLevel: "SHOULD", - ScrapeData: `test_metric{very_long_label_name_that_exceeds_normal_length_but_is_still_valid="value"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for labelName := range labels { - if len(labelName) > 50 { - should(t, len(labelName) > 0, "Long label names should be handled") - t.Logf("Found long label name: %s (length: %d)", labelName, len(labelName)) - } - } - } - }, - }, - { - Name: "very_long_label_values", - Description: "Sender SHOULD handle long label values (within reasonable limits)", - RFCLevel: "SHOULD", - ScrapeData: `test_metric{description="This is a very long label value that contains a lot of text to test how senders handle long strings in label values which might be common in real-world scenarios"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - for _, value := range labels { - if len(value) > 100 { - should(t, len(value) > 0, "Long label values should be handled") - t.Logf("Found long label value (length: %d)", len(value)) - } - } - } - }, - }, - { - Name: "many_labels_per_series", - Description: "Sender SHOULD handle timeseries with many labels", - RFCLevel: "SHOULD", - ScrapeData: `test_metric{l1="v1",l2="v2",l3="v3",l4="v4",l5="v5",l6="v6",l7="v7",l8="v8",l9="v9",l10="v10"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if len(labels) > 5 { - should(t, len(labels) >= 5, "Sender should handle timeseries with many labels") - t.Logf("Found timeseries with %d labels", len(labels)) - } - } + require.True(t, found, "Expected to find timeseries") }, }, } - - runTestCases(t, tests) } diff --git a/remotewrite/sender/metadata_test.go b/remotewrite/sender/metadata_test.go index be38b309..976a8c5a 100644 --- a/remotewrite/sender/metadata_test.go +++ b/remotewrite/sender/metadata_test.go @@ -14,291 +14,41 @@ package sender import ( - "strings" "testing" - writev2 "github.com/prometheus/prometheus/prompb/io/prometheus/write/v2" + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" ) -// TestMetadataEncoding validates metric metadata encoding. -func TestMetadataEncoding_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []TestCase{ - { - Name: "metadata_type_counter", - Description: "Sender SHOULD include TYPE metadata for counter metrics", - RFCLevel: "SHOULD", - ScrapeData: `# HELP http_requests_total Total HTTP requests -# TYPE http_requests_total counter -http_requests_total 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetadata bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "http_requests_total" { - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - should(t, writev2.Metadata_METRIC_TYPE_COUNTER == ts.Metadata.Type, "Counter metric should have COUNTER type in metadata") - foundMetadata = true - } - break - } - } - should(t, foundMetadata || len(req.Request.Timeseries) > 0, "Metadata should be present for typed metrics") - }, - }, - { - Name: "metadata_type_gauge", - Description: "Sender SHOULD include TYPE metadata for gauge metrics", - RFCLevel: "SHOULD", - ScrapeData: `# HELP memory_usage_bytes Current memory usage -# TYPE memory_usage_bytes gauge -memory_usage_bytes 1048576 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetadata bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "memory_usage_bytes" { - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - should(t, writev2.Metadata_METRIC_TYPE_GAUGE == ts.Metadata.Type, "Gauge metric should have GAUGE type in metadata") - foundMetadata = true - } - break - } - } - should(t, foundMetadata || len(req.Request.Timeseries) > 0, "Metadata should be present for typed metrics") - }, - }, - { - Name: "metadata_type_histogram", - Description: "Sender SHOULD include TYPE metadata for histogram metrics", - RFCLevel: "SHOULD", - ScrapeData: `# HELP request_duration_seconds Request duration -# TYPE request_duration_seconds histogram -request_duration_seconds_bucket{le="+Inf"} 100 -request_duration_seconds_sum 50.0 -request_duration_seconds_count 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetadata bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - - if metricName == "request_duration_seconds_count" || - metricName == "request_duration_seconds" { - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - should(t, writev2.Metadata_METRIC_TYPE_HISTOGRAM == ts.Metadata.Type, "Histogram metric should have HISTOGRAM type in metadata") - foundMetadata = true - break - } - } - } - should(t, foundMetadata || len(req.Request.Timeseries) > 0, "Metadata should be present for histogram metrics") - }, - }, - { - Name: "metadata_type_summary", - Description: "Sender SHOULD include TYPE metadata for summary metrics", - RFCLevel: "SHOULD", - ScrapeData: `# HELP rpc_duration_seconds RPC duration -# TYPE rpc_duration_seconds summary -rpc_duration_seconds{quantile="0.5"} 0.05 -rpc_duration_seconds{quantile="0.9"} 0.1 -rpc_duration_seconds_sum 100.0 -rpc_duration_seconds_count 1000 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundMetadata bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "rpc_duration_seconds" { - if ts.Metadata.Type != writev2.Metadata_METRIC_TYPE_UNSPECIFIED { - should(t, writev2.Metadata_METRIC_TYPE_SUMMARY == ts.Metadata.Type, "Summary metric should have SUMMARY type in metadata") - foundMetadata = true - break - } - } - } - should(t, foundMetadata || len(req.Request.Timeseries) > 0, "Metadata should be present for summary metrics") - }, - }, +func metadataTests() []Test { + return []Test{ { - Name: "metadata_help_text", - Description: "Sender SHOULD include HELP text in metadata", - RFCLevel: "SHOULD", - ScrapeData: `# HELP http_requests_total The total number of HTTP requests -# TYPE http_requests_total counter -http_requests_total 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundHelp bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "http_requests_total" { - if ts.Metadata.HelpRef != 0 { - helpText := req.Request.Symbols[ts.Metadata.HelpRef] - should(t, len(helpText) > 0, "HELP text should be present in metadata") - should(t, strings.Contains(helpText, "HTTP requests"), "HELP text should contain meaningful description") - foundHelp = true - } - break - } - } - should(t, foundHelp || len(req.Request.Timeseries) > 0, "HELP text should be present in metadata") - }, - }, - { - Name: "metadata_unit", - Description: "Sender MAY include UNIT in metadata", - RFCLevel: "MAY", - ScrapeData: `# HELP memory_usage_bytes Memory usage -# TYPE memory_usage_bytes gauge -# UNIT memory_usage_bytes bytes -memory_usage_bytes 1048576 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundUnit bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "memory_usage_bytes" { - if ts.Metadata.UnitRef != 0 { - unit := req.Request.Symbols[ts.Metadata.UnitRef] - may(t, len(unit) > 0, "UNIT may be present in metadata") - t.Logf("Found unit in metadata: %s", unit) - foundUnit = true - } - break - } - } - may(t, foundUnit || len(req.Request.Timeseries) > 0, "UNIT may be present in metadata") - }, - }, - { - Name: "metadata_help_with_newlines", - Description: "Sender SHOULD preserve newlines in HELP text", - RFCLevel: "SHOULD", - ScrapeData: `# HELP multiline_metric This is a help text -# HELP multiline_metric that spans multiple lines -# TYPE multiline_metric gauge -multiline_metric 42 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - // Note: Prometheus exposition format doesn't actually support - // multi-line HELP. This test validates handling of the format. - var foundMetadata bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "multiline_metric" { - if ts.Metadata.HelpRef != 0 { - helpText := req.Request.Symbols[ts.Metadata.HelpRef] - should(t, len(helpText) > 0, "HELP text should be present") - foundMetadata = true - } - break - } - } - should(t, foundMetadata || len(req.Request.Timeseries) > 0, "Metadata should be handled correctly") - }, - }, - { - Name: "metadata_help_with_special_chars", - Description: "Sender SHOULD preserve special characters in HELP text", - RFCLevel: "SHOULD", - ScrapeData: `# HELP special_metric This help contains "quotes" and \backslashes\ -# TYPE special_metric counter -special_metric 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var foundSpecial bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "special_metric" { - if ts.Metadata.HelpRef != 0 { - helpText := req.Request.Symbols[ts.Metadata.HelpRef] - should(t, len(helpText) > 0, "HELP text with special characters should be preserved") - foundSpecial = true - } - break - } - } - should(t, foundSpecial || len(req.Request.Timeseries) > 0, "Special characters in metadata should be handled") - }, - }, - { - Name: "metadata_help_refs_valid", - Description: "Metadata HelpRef MUST point to valid symbol table index if non-zero", - RFCLevel: "MUST", - ScrapeData: `# HELP test_metric Test metric description + Name: "metadata_fields_valid", + Description: "Sender SHOULD send valid metadata", + RFCLevel: ShouldLevel, + Version: remote.WriteV2MessageType, + ScrapeData: `# HELP test_metric A test metric # TYPE test_metric counter +# UNIT test_metric bytes test_metric 42 `, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - for _, ts := range req.Request.Timeseries { - if ts.Metadata.HelpRef != 0 { - must(t).Less(int(ts.Metadata.HelpRef), len(symbols), - "HelpRef must point to valid symbol index") - } - if ts.Metadata.UnitRef != 0 { - must(t).Less(int(ts.Metadata.UnitRef), len(symbols), - "UnitRef must point to valid symbol index") - } - } - }, - }, - { - Name: "metadata_consistent_across_series", - Description: "Sender SHOULD send consistent metadata for the same metric family", - RFCLevel: "SHOULD", - ScrapeData: `# HELP http_requests_total Total HTTP requests -# TYPE http_requests_total counter -http_requests_total{method="GET"} 100 -http_requests_total{method="POST"} 50 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - metadataMap := make(map[string]writev2.Metadata) - - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - metricName := labels["__name__"] - - if metricName == "http_requests_total" { - if existing, found := metadataMap[metricName]; found { - // If metadata exists, it should be consistent. - should(t, existing.Type == ts.Metadata.Type, "Metadata type should be consistent for same metric family") - should(t, existing.HelpRef == ts.Metadata.HelpRef, "Metadata help should be consistent for same metric family") - } else { - metadataMap[metricName] = ts.Metadata + Validate: func(t *testing.T, res ReceiverResult) { + var foundMetadata bool + for _, req := range res.Requests { + if req.RW2 != nil { + for _, ts := range req.RW2.Timeseries { + if ts.Metadata.Type != 0 { + foundMetadata = true + require.Less(t, int(ts.Metadata.HelpRef), len(req.RW2.Symbols), "Help ref out of bounds") + require.Less(t, int(ts.Metadata.UnitRef), len(req.RW2.Symbols), "Unit ref out of bounds") + } } } } - }, - }, - { - Name: "metadata_empty_help_allowed", - Description: "Sender MAY send metrics without HELP text", - RFCLevel: "MAY", - ScrapeData: `# TYPE no_help_metric counter -no_help_metric 42 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - var found bool - for _, ts := range req.Request.Timeseries { - labels := extractLabels(&ts, req.Request.Symbols) - if labels["__name__"] == "no_help_metric" { - // HelpRef may be 0 (empty string) which is valid. - may(t, int(ts.Metadata.HelpRef) >= 0, "Empty HELP text is allowed") - found = true - break - } + if !foundMetadata { + t.Log("Sender did not send metadata (optional)") } - may(t, found || len(req.Request.Timeseries) > 0, "Metrics without HELP are allowed") }, }, } - - runTestCases(t, tests) } diff --git a/remotewrite/sender/protocol_test.go b/remotewrite/sender/protocol_test.go index d311dd9a..7e6dad98 100644 --- a/remotewrite/sender/protocol_test.go +++ b/remotewrite/sender/protocol_test.go @@ -14,134 +14,36 @@ package sender import ( - "fmt" - "strings" "testing" -) -// TestProtocolCompliance validates HTTP protocol requirements for Remote Write 2.0 senders. -func TestProtocolCompliance_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []TestCase{ - { - Name: "content_type_protobuf", - Description: "Sender MUST use Content-Type: application/x-protobuf", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - contentType := req.Headers.Get("Content-Type") - must(t).Contains(contentType, "application/x-protobuf", - "Content-Type header must contain application/x-protobuf") - }, - }, - { - Name: "content_type_with_proto_param", - Description: "Sender SHOULD include proto parameter in Content-Type for RW 2.0", - RFCLevel: "SHOULD", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - contentType := req.Headers.Get("Content-Type") - should(t, strings.Contains(contentType, "proto=io.prometheus.write.v2.Request"), "Content-Type should include proto parameter for RW 2.0") - }, - }, - { - Name: "content_encoding_snappy", - Description: "Sender MUST use Content-Encoding: snappy", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - encoding := req.Headers.Get("Content-Encoding") - must(t).Equal("snappy", encoding, - "Content-Encoding header must be 'snappy'") - }, - }, - { - Name: "version_header_present", - Description: "Sender MUST include X-Prometheus-Remote-Write-Version header", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - must(t).NotEmpty(version, - "X-Prometheus-Remote-Write-Version header must be present") - }, - }, - { - Name: "version_header_value", - Description: "Sender SHOULD use version 2.0.0 for RW 2.0 receivers", - RFCLevel: "SHOULD", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - should(t, strings.HasPrefix(version, "2.0"), fmt.Sprintf("Version should be 2.0.x for RW 2.0, got: %s", version)) - }, - }, - { - Name: "user_agent_present", - Description: "Sender MUST include User-Agent header (RFC 9110)", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - userAgent := req.Headers.Get("User-Agent") - must(t).NotEmpty(userAgent, - "User-Agent header must be present per RFC 9110") - }, - }, +func protocolTests() []Test { + return []Test{ { - Name: "snappy_block_format", - Description: "Sender MUST use snappy block format, not framed format", - RFCLevel: "MUST", + Name: "protocol_headers_and_method", + Description: "Sender MUST use POST, Snappy, and set required headers", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - body := req.Body - must(t).NotEmpty(body, "Request body must not be empty") + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 1) + for _, req := range res.Requests { + require.Equal(t, "POST", req.Method, "HTTP method MUST be POST") + require.Equal(t, "snappy", req.Headers.Get("Content-Encoding"), "Content-Encoding MUST be snappy") + + contentType := req.Headers.Get("Content-Type") + require.Contains(t, contentType, "application/x-protobuf", "Content-Type MUST be protobuf") - // Check that it doesn't start with snappy framed format magic bytes. - if len(body) >= 10 { - framedMagic := []byte{0xff, 0x06, 0x00, 0x00, 0x73, 0x4e, 0x61, 0x50, 0x50, 0x59} - isFramed := true - for i := 0; i < 10; i++ { - if body[i] != framedMagic[i] { - isFramed = false - break - } - } - must(t).False(isFramed, - "Sender must use snappy block format, not framed format") + // SHOULD headers + require.NotEmpty(t, req.Headers.Get("User-Agent"), "User-Agent SHOULD be set") + + // The X-Prometheus-Remote-Write-Version header is required for 2.0 + require.Equal(t, "2.0.0", req.Headers.Get("X-Prometheus-Remote-Write-Version"), "X-Prometheus-Remote-Write-Version MUST be 2.0.0 for V2") } }, }, - { - Name: "protobuf_parseable", - Description: "Sender MUST send valid protobuf messages that can be parsed", - RFCLevel: "MUST", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - // The request was already parsed in MockReceiver.handleRequest. If we got here, the protobuf was successfully parsed. - must(t).NotNil(req.Request, "Protobuf message must be parseable") - must(t).NotEmpty(req.Request.Symbols, - "Parsed request must contain symbols") - }, - }, } - - runTestCases(t, tests) -} - -// TestHTTPMethod validates that senders use POST method for remote write. -func TestHTTPMethod_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "MUST") - t.Attr("description", "Sender MUST use POST method for remote write requests") - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - runSenderTest(t, targetName, target, SenderTestScenario{ - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - must(t).NotNil(req, "Request must be received successfully") - }, - }) - }) } diff --git a/remotewrite/sender/response_test.go b/remotewrite/sender/response_test.go index 97943363..1583b45c 100644 --- a/remotewrite/sender/response_test.go +++ b/remotewrite/sender/response_test.go @@ -15,265 +15,27 @@ package sender import ( "net/http" - "strings" "testing" -) - -// TestResponseProcessing validates sender response header processing. -func TestResponseProcessing_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []struct { - name string - description string - rfcLevel string - scrapeData string - setup func(*MockReceiver) - validator func(*testing.T, []CapturedRequest) - }{ - { - name: "ignore_response_body_on_success", - description: "Sender SHOULD ignore response body on 2xx success", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - Body: "This body should be ignored", - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Should receive at least one request") - - // Sender should accept 204 with body. - should(t, true, "Sender should ignore response body on successful requests") - t.Logf("Received %d successful requests", len(requests)) - }, - }, - { - name: "process_written_count_headers", - description: "Sender MAY use X-Prometheus-Remote-Write-*-Written headers", - rfcLevel: "MAY", - scrapeData: `# Multiple samples -test_counter_total{label="a"} 100 -test_counter_total{label="b"} 200 -test_gauge{label="c"} 50 -`, - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - SamplesWritten: 3, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - may(t, len(requests) >= 1, "Should receive at least one request") - - // Sender may use these headers for optimization/tracking. - may(t, true, "Sender may process X-Prometheus-Remote-Write-*-Written headers") - t.Logf("Sent response with written count headers") - }, - }, - { - name: "handle_partial_write_response", - description: "Sender SHOULD handle partial write responses (some data accepted)", - rfcLevel: "SHOULD", - scrapeData: `# Multiple samples -sample_1 1 -sample_2 2 -sample_3 3 -`, - setup: func(mr *MockReceiver) { - // Indicate only 2 out of 3 samples were written. - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadRequest, - Body: "Rejected 1 sample", - SamplesWritten: 2, // Partial acceptance - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Should receive at least one request") - - // Sender should handle partial writes. - should(t, true, "Sender should handle partial write responses") - t.Logf("Handled partial write response") - }, - }, - { - name: "handle_missing_written_headers", - description: "Sender SHOULD assume 0 written if headers missing on 4xx/5xx", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - // Return error without written count headers. - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadRequest, - Body: "Bad request", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Should receive request even with error") - - // Sender should assume nothing was written. - should(t, true, "Sender should assume 0 written when headers missing") - t.Logf("Handled missing written count headers") - }, - }, - { - name: "log_error_messages_verbatim", - description: "Sender MUST log error messages as-is without interpretation", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadRequest, - Body: "Error: Invalid label name '__invalid__'", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - must(t).GreaterOrEqual(len(requests), 1, - "Should receive request") - - // Sender should log the error message without modification. - must(t).True(true, - "Sender must log error messages verbatim") - t.Logf("Error response sent to sender") - }, - }, - { - name: "handle_large_error_body", - description: "Sender SHOULD handle large error response bodies", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - largeError := "Error details: " - for i := 0; i < 1000; i++ { - largeError += "detailed error information; " - } - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadRequest, - Body: largeError, - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Should handle large error bodies") - - should(t, true, "Sender should handle large error response bodies") - t.Logf("Handled large error response body") - }, - }, - { - name: "handle_204_no_content", - description: "Sender MUST accept 204 No Content as success", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - must(t).GreaterOrEqual(len(requests), 1, - "Should successfully send to 204 endpoint") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - must(t).True(true, - "Sender must accept 204 No Content as successful response") - t.Logf("Successfully handled 204 No Content") - }, - }, +func responseTests() []Test { + return []Test{ { - name: "handle_200_ok", - description: "Sender MUST accept 200 OK as success", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusOK, - Body: "OK", - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) + Name: "handle_20x_response", + Description: "Sender MUST handle 204 or 200 responses", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusOK}, + {StatusCode: http.StatusNoContent}, }, - validator: func(t *testing.T, requests []CapturedRequest) { - must(t).GreaterOrEqual(len(requests), 1, - "Should successfully send to 200 endpoint") - - must(t).True(true, - "Sender must accept 200 OK as successful response") - t.Logf("Successfully handled 200 OK") + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 1, "Sender should handle 200/204 response") }, }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - t.Attr("rfcLevel", tt.rfcLevel) - t.Attr("description", tt.description) - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - tt.setup(receiver) - - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - t.Logf("Running %s with scrape target %s and receiver %s", targetName, scrapeTarget.URL(), receiver.URL()) - - t.Fatal("was creating target here; to remove") - - requests := receiver.GetRequests() - tt.validator(t, requests) - }) - }) - } -} - -// TestContentTypeNegotiation validates content-type handling. -func TestContentTypeNegotiation_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "SHOULD") - t.Attr("description", "Sender SHOULD handle content-type negotiation") - - scrapeData := "test_metric 42\n" - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - receiver.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNoContent, - SamplesWritten: 1, - ExemplarsWritten: 0, - HistogramsWritten: 0, - }) - - scrapeTarget := NewMockScrapeTarget(scrapeData) - defer scrapeTarget.Close() - - t.Fatal("was creating target here; to remove") - - requests := receiver.GetRequests() - should(t, len(requests) >= 1, "Should send at least one request") - - if len(requests) > 0 { - contentType := requests[0].Headers.Get("Content-Type") - should(t, strings.Contains(contentType, "application/x-protobuf"), "Should use protobuf content-type") - t.Logf("Content-Type: %s", contentType) - } - }) } diff --git a/remotewrite/sender/retry_test.go b/remotewrite/sender/retry_test.go index 7c42201c..62fd31a4 100644 --- a/remotewrite/sender/retry_test.go +++ b/remotewrite/sender/retry_test.go @@ -14,203 +14,72 @@ package sender import ( - "fmt" "net/http" "testing" -) -// TestRetryBehavior validates sender retry behavior on different error responses. -func TestRetryBehavior_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []struct { - name string - description string - rfcLevel string - scrapeData string - setup func(*MockReceiver) - validator func(*testing.T, []CapturedRequest) - }{ +func retryTests() []Test { + return []Test{ { - name: "no_retry_on_400", - description: "Sender MUST NOT retry on 400 Bad Request", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - // Always return 400. - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadRequest, - Body: "Bad request", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - // Should receive exactly 1 request (no retries). Allow up to 2 for initial attempt + possible single retry before detecting 4xx. - should(t, len(requests) <= 2, fmt.Sprintf( - "Sender should not retry on 400 Bad Request, got %d requests", len(requests))) - t.Logf("Received %d requests for 400 response", len(requests)) + Name: "no_retry_on_400", + Description: "Sender MUST NOT retry on 400 Bad Request", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusBadRequest}, + {StatusCode: http.StatusBadRequest}, + }, + Validate: func(t *testing.T, res ReceiverResult) { + require.LessOrEqual(t, len(res.Requests), 2, "Sender should not retry on 400 Bad Request") }, }, { - name: "no_retry_on_401", - description: "Sender MUST NOT retry on 401 Unauthorized", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusUnauthorized, - Body: "Unauthorized", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) <= 2, fmt.Sprintf( - "Sender should not retry on 401 Unauthorized, got %d requests", len(requests))) - t.Logf("Received %d requests for 401 response", len(requests)) + Name: "no_retry_on_401", + Description: "Sender MUST NOT retry on 401 Unauthorized", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusUnauthorized}, + {StatusCode: http.StatusUnauthorized}, + }, + Validate: func(t *testing.T, res ReceiverResult) { + require.LessOrEqual(t, len(res.Requests), 2, "Sender should not retry on 401 Unauthorized") }, }, { - name: "no_retry_on_404", - description: "Sender MUST NOT retry on 404 Not Found", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusNotFound, - Body: "Not found", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) <= 2, fmt.Sprintf( - "Sender should not retry on 404 Not Found, got %d requests", len(requests))) - t.Logf("Received %d requests for 404 response", len(requests)) + Name: "retry_on_500", + Description: "Sender MUST retry on 500 Internal Server Error", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusInternalServerError}, + {StatusCode: http.StatusInternalServerError}, + {StatusCode: http.StatusNoContent}, + }, + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 3, "Sender should retry on 500 Internal Server Error") }, }, { - name: "may_retry_on_429", - description: "Sender MAY retry on 429 Too Many Requests", - rfcLevel: "MAY", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusTooManyRequests, - Headers: map[string]string{ - "Retry-After": "1", - }, - Body: "Too many requests", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - // 429 retry behavior is optional. - may(t, len(requests) >= 1, "Sender may retry on 429 Too Many Requests") - t.Logf("Received %d requests for 429 response (retry optional)", len(requests)) + Name: "retry_on_503", + Description: "Sender MUST retry on 503 Service Unavailable", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, + ScrapeData: "test_metric 42\n", + TestResponses: []ReceiverResponse{ + {StatusCode: http.StatusServiceUnavailable}, + {StatusCode: http.StatusServiceUnavailable}, + {StatusCode: http.StatusNoContent}, + }, + Validate: func(t *testing.T, res ReceiverResult) { + require.GreaterOrEqual(t, len(res.Requests), 3, "Sender should retry on 503 Service Unavailable") }, }, - { - name: "retry_on_500", - description: "Sender MUST retry on 500 Internal Server Error", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusInternalServerError, - Body: "Internal server error", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - // Should retry on 500 (expect multiple attempts). - // Note: Some senders may give up after a few retries. We just check that at least one request was made. - must(t).GreaterOrEqual(len(requests), 1, - "Sender should attempt request on 500 Internal Server Error") - t.Logf("Received %d requests for 500 response (retries expected)", len(requests)) - }, - }, - { - name: "retry_on_503", - description: "Sender MUST retry on 503 Service Unavailable", - rfcLevel: "MUST", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusServiceUnavailable, - Body: "Service unavailable", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - must(t).GreaterOrEqual(len(requests), 1, - "Sender should attempt request on 503 Service Unavailable") - t.Logf("Received %d requests for 503 response", len(requests)) - }, - }, - { - name: "retry_on_502", - description: "Sender SHOULD retry on 502 Bad Gateway", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusBadGateway, - Body: "Bad gateway", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Sender should retry on 502 Bad Gateway") - t.Logf("Received %d requests for 502 response", len(requests)) - }, - }, - { - name: "retry_on_504", - description: "Sender SHOULD retry on 504 Gateway Timeout", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusGatewayTimeout, - Body: "Gateway timeout", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) >= 1, "Sender should retry on 504 Gateway Timeout") - t.Logf("Received %d requests for 504 response", len(requests)) - }, - }, - { - name: "no_retry_on_413", - description: "Sender SHOULD NOT retry on 413 Payload Too Large", - rfcLevel: "SHOULD", - scrapeData: "test_metric 42\n", - setup: func(mr *MockReceiver) { - mr.SetResponse(MockReceiverResponse{ - StatusCode: http.StatusRequestEntityTooLarge, - Body: "Payload too large", - }) - }, - validator: func(t *testing.T, requests []CapturedRequest) { - should(t, len(requests) <= 2, fmt.Sprintf( - "Sender should not retry on 413 Payload Too Large, got %d requests", len(requests))) - t.Logf("Received %d requests for 413 response", len(requests)) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - t.Attr("rfcLevel", tt.rfcLevel) - t.Attr("description", tt.description) - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - receiver := NewMockReceiver() - defer receiver.Close() - - tt.setup(receiver) - - scrapeTarget := NewMockScrapeTarget(tt.scrapeData) - defer scrapeTarget.Close() - - t.Fatal("was creating target here; to remove") - - requests := receiver.GetRequests() - tt.validator(t, requests) - }) - }) } } diff --git a/remotewrite/sender/rw1_compat_test.go b/remotewrite/sender/rw1_compat_test.go index 25145733..68648f45 100644 --- a/remotewrite/sender/rw1_compat_test.go +++ b/remotewrite/sender/rw1_compat_test.go @@ -14,230 +14,32 @@ package sender import ( - "strings" "testing" -) - -// TestRemoteWrite1Compatibility validates RW 1.0 backward compatibility. -// Note: These tests require sender to be configured for RW 1.0 mode. -// Most senders default to RW 2.0, so RW 1.0 tests are informational. -func TestRemoteWrite1Compatibility_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - tests := []TestCase{ - { - Name: "rw1_version_header", - Description: "When using RW 1.0, sender SHOULD use version 0.1.0", - RFCLevel: "SHOULD", - ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - // Check if this is RW 1.0 or RW 2.0. - if strings.HasPrefix(version, "2.0") { - // This is RW 2.0, skip RW 1.0 validation. - t.Logf("Sender using RW 2.0 (version: %s), skipping RW 1.0 test", version) - return - } + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - if strings.HasPrefix(version, "0.1") || version == "" { - should(t, true, "RW 1.0 version header is acceptable") - t.Logf("RW 1.0 detected with version: %s", version) - } else { - t.Logf("Unknown version: %s", version) - } - }, - }, +func rw1CompatTests() []Test { + return []Test{ { - Name: "rw1_content_type", - Description: "RW 1.0 SHOULD use basic content-type without proto parameter", - RFCLevel: "SHOULD", + Name: "rw1_fallback_supported", + Description: "Senders MAY support falling back to PRW 1.0", + RFCLevel: MayLevel, + Version: remote.WriteV1MessageType, ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - contentType := req.Headers.Get("Content-Type") - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - // Only validate if this is RW 1.0. - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 content-type test") - return - } - - // RW 1.0 typically uses simple "application/x-protobuf". - should(t, strings.Contains(contentType, "application/x-protobuf"), "RW 1.0 should use protobuf content-type") - - // RW 1.0 should NOT have proto parameter (that's RW 2.0). - if strings.Contains(contentType, "proto=io.prometheus.write.v2") { - t.Logf("Warning: RW 1.0 should not use v2 proto parameter") - } - }, - }, - { - Name: "rw1_samples_encoding", - Description: "RW 1.0 MUST encode samples correctly", - RFCLevel: "MUST", - ScrapeData: "test_counter_total 100\n", - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 sample encoding test") - return - } - - must(t).NotNil(req.Request, "Request should be parseable") - t.Logf("RW 1.0 samples encoded") - }, - }, - { - Name: "rw1_labels_encoding", - Description: "RW 1.0 MUST encode labels correctly", - RFCLevel: "MUST", - ScrapeData: `test_metric{label="value"} 42`, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 label encoding test") - return - } - - // Validate that request contains data. - must(t).NotNil(req.Request, "Request should contain label data") - t.Logf("RW 1.0 labels encoded") - }, - }, - { - Name: "rw1_no_native_histograms", - Description: "RW 1.0 does not support native histograms", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_histogram histogram -test_histogram_count 100 -test_histogram_sum 250.0 -test_histogram_bucket{le="+Inf"} 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 histogram test") - return - } - - // RW 1.0 should send histogram as separate timeseries (classic format). - // Should NOT use native histogram encoding. - for _, ts := range req.Request.Timeseries { - must(t).Empty(ts.Histograms, - "RW 1.0 should not use native histogram encoding") - } - - t.Logf("RW 1.0: Histograms sent as classic format (separate series)") - }, - }, - { - Name: "rw1_no_start_timestamp", - Description: "RW 1.0 does not support start_timestamp field", - RFCLevel: "MUST", - ScrapeData: `# TYPE test_counter counter -test_counter_total 100 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 start_timestamp test") - return - } - - // RW 1.0 format doesn't have start_timestamp field. - // If sender is truly in RW 1.0 mode, this field should be 0/unset. - for _, ts := range req.Request.Timeseries { - for _, sample := range ts.Samples { - should(t, int64(0) == sample.StartTimestamp, "RW 1.0 should not use start_timestamp field in samples") + Validate: func(t *testing.T, res ReceiverResult) { + var foundRW1 bool + for _, req := range res.Requests { + if req.RW1 != nil { + foundRW1 = true + require.NotEmpty(t, req.RW1.Timeseries, "RW1 timeseries should not be empty") } } - - t.Logf("RW 1.0: No start_timestamp field used") - }, - }, - { - Name: "rw1_metadata_handling", - Description: "RW 1.0 MAY send metadata separately", - RFCLevel: "MAY", - ScrapeData: `# HELP test_metric Test metric description -# TYPE test_metric counter -test_metric 42 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected, skipping RW 1.0 metadata test") - return + if !foundRW1 { + t.Log("Sender did not send PRW 1.0 payload (optional)") } - - // RW 1.0 has limited metadata support. - // Metadata is typically sent via separate API endpoint. - may(t, req.Request != nil, "RW 1.0 may handle metadata differently") - t.Logf("RW 1.0: Metadata handling validated") - }, - }, - { - Name: "rw1_symbol_table_not_used", - Description: "RW 1.0 does not use symbol table optimization", - RFCLevel: "MUST", - ScrapeData: `http_requests{method="GET",status="200"} 100 -http_requests{method="POST",status="200"} 50 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - if strings.HasPrefix(version, "2.0") { - t.Logf("RW 2.0 detected (uses symbol table), skipping RW 1.0 test") - return - } - - // RW 1.0 doesn't use symbol table - labels are sent inline. - // If this is truly RW 1.0, symbol table should be minimal or empty. - // (RW 2.0 proto may still parse it but values should be inline). - may(t, req.Request != nil, "RW 1.0 format validated") - t.Logf("RW 1.0: Symbol table not used (labels inline)") }, }, } - - runTestCases(t, tests) -} - -// TestRemoteWrite1Configuration tests if sender can be configured for RW 1.0. -func TestRemoteWrite1Configuration_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "SHOULD") - t.Attr("description", "Sender SHOULD support RW 1.0 configuration for backward compatibility") - - scrapeData := "test_metric 42\n" - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - runSenderTest(t, targetName, target, SenderTestScenario{ - ScrapeData: scrapeData, - Validator: func(t *testing.T, req *CapturedRequest) { - version := req.Headers.Get("X-Prometheus-Remote-Write-Version") - - // Check what version is being used. - if version == "" { - should(t, len(version) > 0, "Version header should be present") - t.Logf("No version header, may default to RW 1.0") - } else if strings.HasPrefix(version, "0.1") { - should(t, true, "Sender configured for RW 1.0") - t.Logf("RW 1.0 mode: version %s", version) - } else if strings.HasPrefix(version, "2.0") { - should(t, true, "Sender configured for RW 2.0") - t.Logf("RW 2.0 mode: version %s (RW 1.0 support may be configurable)", version) - } else { - t.Logf("Unknown version: %s", version) - } - }, - }) - }) } diff --git a/remotewrite/sender/symbols_test.go b/remotewrite/sender/symbols_test.go index 3c0ed53b..45bb90d7 100644 --- a/remotewrite/sender/symbols_test.go +++ b/remotewrite/sender/symbols_test.go @@ -14,138 +14,35 @@ package sender import ( - "fmt" "testing" -) -// TestSymbolTable validates symbol table requirements for Remote Write 2.0. -func TestSymbolTable_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") + "github.com/prometheus/client_golang/exp/api/remote" + "github.com/stretchr/testify/require" +) - tests := []TestCase{ +func symbolsTests() []Test { + return []Test{ { - Name: "empty_string_at_index_zero", - Description: "Symbol table MUST have empty string at index 0", - RFCLevel: "MUST", + Name: "symbols_table_valid", + Description: "Symbol table MUST have empty string at index 0 and valid refs", + RFCLevel: MustLevel, + Version: remote.WriteV2MessageType, ScrapeData: "test_metric 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - must(t).NotEmpty(symbols, "Symbol table must not be empty") - must(t).Equal("", symbols[0], - "Symbol at index 0 must be empty string, got: %q", symbols[0]) - }, - }, - { - Name: "string_deduplication", - Description: "Symbol table should deduplicate repeated strings for efficiency", - RFCLevel: "RECOMMENDED", - ScrapeData: `# Multiple metrics with same label keys/values -test_metric{foo="bar",baz="qux"} 1 -test_metric{foo="bar",baz="qux"} 2 -another_metric{foo="bar"} 3 -`, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - must(t).NotEmpty(symbols, "Symbol table must not be empty") - - // Check for duplicate non-empty strings. - seen := make(map[string]int) - for i, sym := range symbols { - if sym == "" { - continue // Empty string can appear multiple times (though should only be at index 0). - } - if prevIdx, exists := seen[sym]; exists { - recommended(t, false, fmt.Sprintf("Duplicate string %q found at indices %d and %d (deduplication is a performance recommended)", - sym, prevIdx, i)) - } - seen[sym] = i - } - }, - }, - { - Name: "labels_refs_valid_indices", - Description: "All label refs MUST point to valid symbol table indices", - RFCLevel: "MUST", - ScrapeData: "test_metric{label=\"value\"} 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - timeseries := req.Request.Timeseries - - must(t).NotEmpty(timeseries, "Request must contain at least one timeseries") - - for tsIdx, ts := range timeseries { - for refIdx, ref := range ts.LabelsRefs { - must(t).Less(int(ref), len(symbols), - "Timeseries[%d].LabelsRefs[%d] = %d points outside symbol table (size: %d)", - tsIdx, refIdx, ref, len(symbols)) + Validate: func(t *testing.T, res ReceiverResult) { + for _, req := range res.Requests { + if req.RW2 != nil { + require.NotEmpty(t, req.RW2.Symbols, "Symbol table must not be empty") + require.Equal(t, "", req.RW2.Symbols[0], "Symbol at index 0 must be empty string") + + for _, ts := range req.RW2.Timeseries { + require.Equal(t, 0, len(ts.LabelsRefs)%2, "Labels refs must be even length") + for _, ref := range ts.LabelsRefs { + require.Less(t, int(ref), len(req.RW2.Symbols), "Label ref out of bounds") + } + } } } }, }, - { - Name: "labels_refs_even_length", - Description: "Label refs array length MUST be even (key-value pairs)", - RFCLevel: "MUST", - ScrapeData: "test_metric{label=\"value\"} 42\n", - Validator: func(t *testing.T, req *CapturedRequest) { - timeseries := req.Request.Timeseries - must(t).NotEmpty(timeseries, "Request must contain at least one timeseries") - - for tsIdx, ts := range timeseries { - refsLen := len(ts.LabelsRefs) - must(t).Equal(0, refsLen%2, - "Timeseries[%d].LabelsRefs has odd length %d (must be even for key-value pairs)", - tsIdx, refsLen) - } - }, - }, } - - runTestCases(t, tests) -} - -// TestSymbolTableEfficiency validates that symbol tables are efficiently constructed. -func TestSymbolTableEfficiency_Old(t *testing.T) { - t.Skip("TODO: Revise and move to a new framework") - - t.Attr("rfcLevel", "RECOMMENDED") - t.Attr("description", "Symbol table should be efficiently constructed with good compression") - - scrapeData := `# Multiple series with shared labels -http_requests_total{method="GET",status="200",handler="/api/v1"} 100 -http_requests_total{method="POST",status="200",handler="/api/v1"} 50 -http_requests_total{method="GET",status="404",handler="/api/v1"} 10 -http_requests_total{method="GET",status="200",handler="/api/v2"} 75 -` - - forEachSender(t, func(t *testing.T, targetName string, target Sender) { - runSenderTest(t, targetName, target, SenderTestScenario{ - ScrapeData: scrapeData, - Validator: func(t *testing.T, req *CapturedRequest) { - symbols := req.Request.Symbols - - // With deduplication, common strings like "http_requests_total", "method", - // "status", "handler", "200", "GET", "/api/v1" should appear only once. - // Without deduplication, the symbol table would be much larger. - - // Count unique non-empty symbols. - uniqueCount := 0 - for _, sym := range symbols { - if sym != "" { - uniqueCount++ - } - } - - // For the above scrape data, we expect around 11-15 unique symbols: - // metric name (1), label keys (3), label values (7-8) - // If the symbol table is much larger, deduplication may not be working. - recommended(t, uniqueCount <= 30, fmt.Sprintf( - "Symbol table should be efficiently deduplicated (found %d unique symbols)", - uniqueCount)) - - t.Logf("Symbol table contains %d unique symbols (total %d entries)", - uniqueCount, len(symbols)) - }, - }) - }) }