diff --git a/test/pkg/environment/common/karpenter_metrics_poller.go b/test/pkg/environment/common/karpenter_metrics_poller.go index 8c268bb43d..8d4ebc25a7 100644 --- a/test/pkg/environment/common/karpenter_metrics_poller.go +++ b/test/pkg/environment/common/karpenter_metrics_poller.go @@ -17,7 +17,6 @@ limitations under the License. package common import ( - "bytes" "context" "errors" "fmt" @@ -27,8 +26,6 @@ import ( "github.com/montanaflynn/stats" . "github.com/onsi/ginkgo/v2" dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - "github.com/prometheus/common/model" ) type ResourceSample struct { @@ -211,20 +208,12 @@ func (mp *KarpenterMetricsPoller) recordSample(state *pollerState, now time.Time // scrapeMetrics uses the API server pod proxy to fetch /metrics from the Karpenter pod. func (mp *KarpenterMetricsPoller) scrapeMetrics(ctx context.Context, podName string) (memBytes float64, cpuSeconds float64, err error) { - data, err := mp.env.KubeClient.CoreV1().Pods("kube-system").ProxyGet("http", podName, "8080", "/metrics", nil).DoRaw(ctx) + families, err := scrapeKarpenterMetricFamilies(ctx, mp.env, podName) if err != nil { - return 0, 0, fmt.Errorf("proxy GET /metrics: %w", err) + return 0, 0, err } - - parser := expfmt.NewTextParser(model.UTF8Validation) - families, err := parser.TextToMetricFamilies(bytes.NewReader(data)) - if err != nil { - return 0, 0, fmt.Errorf("parsing metrics: %w", err) - } - memBytes = getGaugeValue(families, "process_resident_memory_bytes") cpuSeconds = getCounterValue(families, "process_cpu_seconds_total") - if memBytes == 0 && cpuSeconds == 0 { return 0, 0, &metricsNotFoundError{foundMem: false, foundCPU: false} } diff --git a/test/pkg/environment/common/latency_harness.go b/test/pkg/environment/common/latency_harness.go new file mode 100644 index 0000000000..1776e0daba --- /dev/null +++ b/test/pkg/environment/common/latency_harness.go @@ -0,0 +1,382 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "bytes" + "context" + "fmt" + "sort" + "strings" + + . "github.com/onsi/ginkgo/v2" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" +) + +// HistogramStats is the derived percentile summary of one labeled histogram +// series over the observations added between LatencyHarness.Start and +// LatencyHarness.Stop. +type HistogramStats struct { + MetricName string `json:"metric_name"` + Labels map[string]string `json:"labels,omitempty"` + Count uint64 `json:"count"` + Sum float64 `json:"sum"` + Mean float64 `json:"mean"` + P50 float64 `json:"p50"` + P90 float64 `json:"p90"` + P95 float64 `json:"p95"` + P99 float64 `json:"p99"` + Max float64 `json:"max"` + BucketTruncationRate float64 `json:"bucket_truncation_rate"` +} + +// TargetHistograms is the Karpenter histogram set the harness scrapes. +var TargetHistograms = []string{ + "karpenter_pods_scheduling_decision_duration_seconds", + "karpenter_pods_bound_duration_seconds", + "karpenter_pods_provisioning_bound_duration_seconds", + "karpenter_pods_provisioning_startup_duration_seconds", + "karpenter_scheduler_scheduling_duration_seconds", + "karpenter_voluntary_disruption_decision_evaluation_duration_seconds", + "karpenter_cloudprovider_duration_seconds", + "karpenter_nodeclaims_instance_termination_duration_seconds", + "karpenter_nodeclaims_termination_duration_seconds", + "karpenter_consolidation_score", +} + +// TargetCounters is the Karpenter counter set the harness reports as deltas +// between Start and Stop, keyed the same way as LatencyStats. +var TargetCounters = []string{ + "karpenter_voluntary_disruption_consolidation_timeouts_total", + "karpenter_consolidation_moves_total", + "karpenter_nodeclaims_created_total", + "karpenter_nodes_created_total", +} + +// LatencyResult is what LatencyHarness.Stop returns. Keys of LatencyStats and +// Counters are series fingerprints (see seriesKey). Process-level memory and +// CPU are covered by KarpenterMetricsPoller; run both harnesses in tandem if +// resource-usage stats are needed. +type LatencyResult struct { + LatencyStats map[string]HistogramStats + Counters map[string]uint64 +} + +// LatencyHarness captures a start-of-phase snapshot of Karpenter's /metrics +// endpoint and produces per-histogram percentile summaries by bucket-count +// delta at Stop. It reuses the pod-proxy scrape pattern from +// KarpenterMetricsPoller. +type LatencyHarness struct { + env *Environment + podName string + start map[string]*dto.MetricFamily +} + +// StartLatencyHarness discovers the active Karpenter pod, scrapes /metrics +// once, and stores a compacted snapshot (target series only) for later delta +// reduction. Symmetric with StartKarpenterMetricsPoller. +func StartLatencyHarness(env *Environment) (*LatencyHarness, error) { + pod, err := env.FindActiveKarpenterPod(env.Context) + if err != nil || pod == nil { + return nil, fmt.Errorf("finding karpenter pod: %w", err) + } + h := &LatencyHarness{env: env, podName: pod.Name} + families, err := scrapeKarpenterMetricFamilies(env.Context, env, pod.Name) + if err != nil { + return nil, fmt.Errorf("initial scrape: %w", err) + } + h.start = compactFamilies(families) + GinkgoWriter.Printf("LatencyHarness: started, scraping pod kube-system/%s\n", pod.Name) + return h, nil +} + +// Stop scrapes the end snapshot and reduces the histogram / counter deltas +// into a LatencyResult. On the first scrape failure the harness refreshes the +// active-pod name once (matches KarpenterMetricsPoller's leader-election +// handling) and retries; a second failure returns the error. +func (h *LatencyHarness) Stop() (*LatencyResult, error) { + ctx := h.env.Context + end, err := scrapeKarpenterMetricFamilies(ctx, h.env, h.podName) + if err != nil { + if pod, findErr := h.env.FindActiveKarpenterPod(ctx); findErr == nil && pod != nil && pod.Name != h.podName { + GinkgoWriter.Printf("LatencyHarness: active pod changed from %s to %s, retrying scrape\n", h.podName, pod.Name) + h.podName = pod.Name + end, err = scrapeKarpenterMetricFamilies(ctx, h.env, pod.Name) + } + if err != nil { + return nil, fmt.Errorf("end scrape: %w", err) + } + } + res := &LatencyResult{ + LatencyStats: map[string]HistogramStats{}, + Counters: map[string]uint64{}, + } + for _, name := range TargetHistograms { + for key, stats := range deltaHistogram(name, h.start[name], end[name]) { + res.LatencyStats[key] = stats + } + } + for _, name := range TargetCounters { + for key, delta := range deltaCounter(name, h.start[name], end[name]) { + res.Counters[key] = delta + } + } + GinkgoWriter.Printf("LatencyHarness: stopped, %d histogram series, %d counter series\n", + len(res.LatencyStats), len(res.Counters)) + return res, nil +} + +// scrapeKarpenterMetricFamilies fetches and parses /metrics from a Karpenter +// pod via the API-server pod proxy. Shared between LatencyHarness and +// KarpenterMetricsPoller. +func scrapeKarpenterMetricFamilies(ctx context.Context, env *Environment, podName string) (map[string]*dto.MetricFamily, error) { + data, err := env.KubeClient.CoreV1().Pods("kube-system").ProxyGet("http", podName, "8080", "/metrics", nil).DoRaw(ctx) + if err != nil { + return nil, fmt.Errorf("proxy GET /metrics: %w", err) + } + parser := expfmt.NewTextParser(model.UTF8Validation) + families, err := parser.TextToMetricFamilies(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("parsing metrics: %w", err) + } + return families, nil +} + +// compactFamilies retains only the metric families the harness reduces at +// Stop, plus process gauges the poller reads. The full Karpenter /metrics +// response contains hundreds of families; retaining only the target set +// keeps memory bounded across long test phases. +func compactFamilies(families map[string]*dto.MetricFamily) map[string]*dto.MetricFamily { + keep := make(map[string]*dto.MetricFamily, len(TargetHistograms)+len(TargetCounters)) + for _, n := range TargetHistograms { + if f, ok := families[n]; ok { + keep[n] = f + } + } + for _, n := range TargetCounters { + if f, ok := families[n]; ok { + keep[n] = f + } + } + return keep +} + +// seriesKey returns the canonical fingerprint for a labeled sample: +// "metric_name" or "metric_name{k=v,k2=v2,...}" with keys sorted lexically. +func seriesKey(name string, labels []*dto.LabelPair) string { + if len(labels) == 0 { + return name + } + pairs := make([]string, 0, len(labels)) + for _, l := range labels { + pairs = append(pairs, l.GetName()+"="+l.GetValue()) + } + sort.Strings(pairs) + return name + "{" + strings.Join(pairs, ",") + "}" +} + +// labelMap returns the labels of a Metric as a plain map for HistogramStats. +func labelMap(labels []*dto.LabelPair) map[string]string { + if len(labels) == 0 { + return nil + } + out := make(map[string]string, len(labels)) + for _, l := range labels { + out[l.GetName()] = l.GetValue() + } + return out +} + +// deltaHistogram computes per-series stats from the count delta between two +// snapshots of the same MetricFamily. Nil start or end families are treated +// as empty. Series present only at end are emitted with their end histogram +// as the whole delta. +func deltaHistogram(name string, start, end *dto.MetricFamily) map[string]HistogramStats { + out := map[string]HistogramStats{} + if end == nil { + return out + } + startBySeries := indexBySeries(name, start) + for _, m := range end.GetMetric() { + if m.GetHistogram() == nil { + continue + } + key := seriesKey(name, m.GetLabel()) + s := reduceHistogramDelta(m.GetHistogram(), startBySeries[key].GetHistogram()) + s.MetricName = name + s.Labels = labelMap(m.GetLabel()) + out[key] = s + } + return out +} + +// deltaCounter computes counter-value deltas between two snapshots. Nil start +// yields the raw end value; a counter reset (end < start) yields end (the new +// baseline is treated as fresh observation). +func deltaCounter(name string, start, end *dto.MetricFamily) map[string]uint64 { + out := map[string]uint64{} + if end == nil { + return out + } + startBySeries := indexBySeries(name, start) + for _, m := range end.GetMetric() { + if m.GetCounter() == nil { + continue + } + key := seriesKey(name, m.GetLabel()) + endV := m.GetCounter().GetValue() + startV := 0.0 + if prev, ok := startBySeries[key]; ok && prev.GetCounter() != nil { + startV = prev.GetCounter().GetValue() + } + delta := endV - startV + if delta < 0 { + // Counter reset (pod restart); take end as-is. endV is a Prometheus + // counter value and cannot be negative. + delta = endV + } + out[key] = uint64(delta) + } + return out +} + +// indexBySeries returns metrics from mf keyed by seriesKey. +func indexBySeries(name string, mf *dto.MetricFamily) map[string]*dto.Metric { + out := map[string]*dto.Metric{} + if mf == nil { + return out + } + for _, m := range mf.GetMetric() { + out[seriesKey(name, m.GetLabel())] = m + } + return out +} + +// reduceHistogramDelta subtracts the start histogram from end (bucket-wise +// and on sample_count / sample_sum) and derives percentile stats over the +// resulting bucket distribution. Both histograms MUST share the same bucket +// layout; deltas for missing start-buckets treat startCumulative as 0. +func reduceHistogramDelta(end *dto.Histogram, startHistogram *dto.Histogram) HistogramStats { + if end == nil { + return HistogramStats{} + } + endCount := end.GetSampleCount() + endSum := end.GetSampleSum() + startCount, startSum, startCumBy := resolveDeltaBaseline(startHistogram, endCount) + deltaCount := endCount - startCount + if deltaCount == 0 { + return HistogramStats{Count: 0, Sum: endSum - startSum} + } + endBuckets := end.GetBucket() + deltaCum := make([]uint64, len(endBuckets)) + for i, b := range endBuckets { + endCum := b.GetCumulativeCount() + startCum := startCumBy[b.GetUpperBound()] + if endCum < startCum { + startCum = 0 + } + deltaCum[i] = endCum - startCum + } + // The +Inf bucket count is deltaCount (per Prometheus contract). Truncation + // rate is what escaped the finite tail. + trunc := 0.0 + if len(deltaCum) > 0 && deltaCount > deltaCum[len(deltaCum)-1] { + trunc = float64(deltaCount-deltaCum[len(deltaCum)-1]) / float64(deltaCount) + } + deltaSum := endSum - startSum + if deltaSum < 0 { + deltaSum = endSum + } + return HistogramStats{ + Count: deltaCount, + Sum: deltaSum, + Mean: deltaSum / float64(deltaCount), + P50: interpolatePercentile(endBuckets, deltaCum, deltaCount, 0.50), + P90: interpolatePercentile(endBuckets, deltaCum, deltaCount, 0.90), + P95: interpolatePercentile(endBuckets, deltaCum, deltaCount, 0.95), + P99: interpolatePercentile(endBuckets, deltaCum, deltaCount, 0.99), + Max: inferMaxBound(endBuckets, deltaCum), + BucketTruncationRate: trunc, + } +} + +// resolveDeltaBaseline returns the baseline sample count, sum, and cumulative +// bucket counts (keyed by upper bound) that the delta reduction subtracts +// from end. A nil startHistogram or a counter-reset (endCount < startCount, +// typically from a pod restart) yields a zero baseline so end is treated as +// the whole delta. +func resolveDeltaBaseline(startHistogram *dto.Histogram, endCount uint64) (uint64, float64, map[float64]uint64) { + if startHistogram == nil { + return 0, 0, nil + } + startCount := startHistogram.GetSampleCount() + if endCount < startCount { + return 0, 0, nil + } + buckets := startHistogram.GetBucket() + cumBy := make(map[float64]uint64, len(buckets)) + for _, b := range buckets { + cumBy[b.GetUpperBound()] = b.GetCumulativeCount() + } + return startCount, startHistogram.GetSampleSum(), cumBy +} + +// inferMaxBound returns the tightest finite bucket upper bound that saw a +// non-zero delta. When every delta observation fell beyond the last finite +// bucket (the +Inf bucket), the last finite bound is returned; callers pair +// this with BucketTruncationRate to detect that condition. +func inferMaxBound(endBuckets []*dto.Bucket, deltaCum []uint64) float64 { + if len(endBuckets) == 0 { + return 0 + } + for i := len(deltaCum) - 1; i >= 0; i-- { + if deltaCum[i] > 0 { + return endBuckets[i].GetUpperBound() + } + } + return endBuckets[len(endBuckets)-1].GetUpperBound() +} + +// interpolatePercentile returns the linearly-interpolated percentile from a +// cumulative delta distribution. Follows Prometheus' histogram_quantile +// convention: uniform-within-bucket, linear from the previous upper bound to +// the current upper bound. Percentiles landing beyond the last finite bucket +// return the last finite bound (a coarse under-estimate under truncation). +func interpolatePercentile(buckets []*dto.Bucket, cum []uint64, total uint64, q float64) float64 { + if total == 0 || len(buckets) == 0 { + return 0 + } + target := q * float64(total) + prevCum := uint64(0) + prevUpper := 0.0 + for i, b := range buckets { + c := cum[i] + if float64(c) >= target { + upper := b.GetUpperBound() + bucketDelta := c - prevCum + if bucketDelta == 0 { + return upper + } + return prevUpper + (upper-prevUpper)*(target-float64(prevCum))/float64(bucketDelta) + } + prevCum = c + prevUpper = b.GetUpperBound() + } + return prevUpper +} diff --git a/test/pkg/environment/common/latency_harness_test.go b/test/pkg/environment/common/latency_harness_test.go new file mode 100644 index 0000000000..cae0bfa964 --- /dev/null +++ b/test/pkg/environment/common/latency_harness_test.go @@ -0,0 +1,297 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "math" + "testing" + + dto "github.com/prometheus/client_model/go" +) + +// mkBucket returns a *dto.Bucket with the given upper bound and cumulative count. +func mkBucket(upper float64, cum uint64) *dto.Bucket { + return &dto.Bucket{UpperBound: &upper, CumulativeCount: &cum} +} + +// mkHistogram returns a *dto.Histogram with the given cumulative buckets and +// total sample_count / sample_sum. The buckets slice MUST be sorted by +// upper bound ascending; cum is cumulative (Prometheus convention). +func mkHistogram(count uint64, sum float64, buckets []*dto.Bucket) *dto.Histogram { + return &dto.Histogram{SampleCount: &count, SampleSum: &sum, Bucket: buckets} +} + +// mkMetric wraps a histogram into a labeled dto.Metric. +func mkMetric(h *dto.Histogram, labels map[string]string) *dto.Metric { + m := &dto.Metric{Histogram: h} + for k, v := range labels { + name, val := k, v + m.Label = append(m.Label, &dto.LabelPair{Name: &name, Value: &val}) + } + return m +} + +// mkFamily wraps a set of Metric into a MetricFamily of the given type. +func mkFamily(name string, mtype dto.MetricType, metrics ...*dto.Metric) *dto.MetricFamily { + n, t := name, mtype + return &dto.MetricFamily{Name: &n, Type: &t, Metric: metrics} +} + +// mkCounterMetric wraps a counter value into a labeled dto.Metric. +func mkCounterMetric(v float64, labels map[string]string) *dto.Metric { + m := &dto.Metric{Counter: &dto.Counter{Value: &v}} + for k, val := range labels { + name, value := k, val + m.Label = append(m.Label, &dto.LabelPair{Name: &name, Value: &value}) + } + return m +} + +// Test 1. Uniform bucket layout, single-series, easy percentiles. +// 100 observations delta split evenly across buckets [0.1, 0.5, 1.0, 2.0]. +// P50 = 0.5 (median lands at bucket 2 upper edge), P90 = 1.6 (interpolated). +func TestReduceHistogramDelta_UniformDistribution(t *testing.T) { + end := mkHistogram(100, 30.0, []*dto.Bucket{ + mkBucket(0.1, 25), + mkBucket(0.5, 50), + mkBucket(1.0, 75), + mkBucket(2.0, 100), + }) + stats := reduceHistogramDelta(end, nil) + if stats.Count != 100 { + t.Errorf("Count: got %d, want 100", stats.Count) + } + if math.Abs(stats.Sum-30.0) > 1e-9 { + t.Errorf("Sum: got %v, want 30.0", stats.Sum) + } + if math.Abs(stats.Mean-0.3) > 1e-9 { + t.Errorf("Mean: got %v, want 0.3", stats.Mean) + } + if math.Abs(stats.P50-0.5) > 1e-9 { + t.Errorf("P50: got %v, want 0.5", stats.P50) + } + // P90 target = 90. prev bucket cum=75, cur=100, prev upper=1.0, cur upper=2.0. + // P90 = 1.0 + (2.0 - 1.0) * (90 - 75) / (100 - 75) = 1.0 + 0.6 = 1.6. + if math.Abs(stats.P90-1.6) > 1e-9 { + t.Errorf("P90: got %v, want 1.6", stats.P90) + } + if stats.BucketTruncationRate != 0 { + t.Errorf("BucketTruncationRate: got %v, want 0", stats.BucketTruncationRate) + } + if math.Abs(stats.Max-2.0) > 1e-9 { + t.Errorf("Max: got %v, want 2.0", stats.Max) + } +} + +// Test 2. Delta reduction subtracts start-of-phase observations correctly. +// Start snapshot has 50 total; end has 150. Delta is 100 with the tail newly +// filled; P50 should reflect only the new observations, not the start pool. +func TestReduceHistogramDelta_SubtractsStartSnapshot(t *testing.T) { + start := mkHistogram(50, 5.0, []*dto.Bucket{ + mkBucket(0.1, 50), + mkBucket(0.5, 50), + mkBucket(1.0, 50), + mkBucket(2.0, 50), + }) + end := mkHistogram(150, 55.0, []*dto.Bucket{ + mkBucket(0.1, 50), // no new observations in this bucket + mkBucket(0.5, 75), + mkBucket(1.0, 100), + mkBucket(2.0, 150), + }) + stats := reduceHistogramDelta(end, start) + if stats.Count != 100 { + t.Errorf("Count: got %d, want 100", stats.Count) + } + if math.Abs(stats.Sum-50.0) > 1e-9 { + t.Errorf("Sum: got %v, want 50.0", stats.Sum) + } + // Delta cumulative buckets: 0, 25, 50, 100. + // P50 target = 50. cum=50 at upper=1.0. Return 1.0 exactly. + if math.Abs(stats.P50-1.0) > 1e-9 { + t.Errorf("P50: got %v, want 1.0", stats.P50) + } + // P90 target = 90. prev cum=50 (upper=1.0), cur cum=100 (upper=2.0). + // P90 = 1.0 + (2.0-1.0) * (90-50)/(100-50) = 1.0 + 0.8 = 1.8. + if math.Abs(stats.P90-1.8) > 1e-9 { + t.Errorf("P90: got %v, want 1.8", stats.P90) + } +} + +// Test 3. Truncation-rate reports observations that fell into +Inf. +// End buckets total 90 within the finite tail while sample_count is 100; +// 10 observations exceeded the top bucket. Truncation rate = 0.10. +func TestReduceHistogramDelta_BucketTruncation(t *testing.T) { + end := mkHistogram(100, 500.0, []*dto.Bucket{ + mkBucket(1.0, 40), + mkBucket(5.0, 70), + mkBucket(10.0, 90), + }) + stats := reduceHistogramDelta(end, nil) + if stats.Count != 100 { + t.Errorf("Count: got %d, want 100", stats.Count) + } + if math.Abs(stats.BucketTruncationRate-0.10) > 1e-9 { + t.Errorf("BucketTruncationRate: got %v, want 0.10", stats.BucketTruncationRate) + } + // P95 target = 95. prev cum=90 (upper=10.0), no next finite bucket -> +Inf. + // Falls back to last finite upper bound. + if math.Abs(stats.P95-10.0) > 1e-9 { + t.Errorf("P95 under truncation: got %v, want 10.0", stats.P95) + } +} + +// Test 4. Zero-observation phase yields zero-valued stats. +func TestReduceHistogramDelta_NoNewObservations(t *testing.T) { + same := mkHistogram(50, 5.0, []*dto.Bucket{ + mkBucket(0.1, 25), + mkBucket(1.0, 50), + }) + stats := reduceHistogramDelta(same, same) + if stats.Count != 0 { + t.Errorf("Count: got %d, want 0", stats.Count) + } + if stats.P50 != 0 || stats.P90 != 0 || stats.P95 != 0 || stats.P99 != 0 { + t.Errorf("percentiles under zero-count: want all zero, got P50=%v P90=%v P95=%v P99=%v", + stats.P50, stats.P90, stats.P95, stats.P99) + } +} + +// Test 5. Counter-reset (pod restart) between snapshots. end_count < start_count +// should fall back to end as fresh observations. +func TestReduceHistogramDelta_CounterReset(t *testing.T) { + start := mkHistogram(200, 50.0, []*dto.Bucket{ + mkBucket(1.0, 200), + mkBucket(5.0, 200), + }) + // Pod restarted; new counter is smaller than the pre-restart baseline. + end := mkHistogram(30, 3.0, []*dto.Bucket{ + mkBucket(1.0, 20), + mkBucket(5.0, 30), + }) + stats := reduceHistogramDelta(end, start) + if stats.Count != 30 { + t.Errorf("Count under reset: got %d, want 30", stats.Count) + } + if math.Abs(stats.Sum-3.0) > 1e-9 { + t.Errorf("Sum under reset: got %v, want 3.0", stats.Sum) + } +} + +// Test 6. Multi-series histogram: same metric name, different label sets. +// deltaHistogram should emit one HistogramStats per (name, label-fingerprint). +func TestDeltaHistogram_MultiSeries(t *testing.T) { + name := "karpenter_voluntary_disruption_decision_evaluation_duration_seconds" + single := mkMetric(mkHistogram(10, 1.0, []*dto.Bucket{ + mkBucket(0.1, 10), + }), map[string]string{"consolidation_type": "single", "reason": "underutilized"}) + multi := mkMetric(mkHistogram(5, 2.5, []*dto.Bucket{ + mkBucket(0.1, 2), + mkBucket(1.0, 5), + }), map[string]string{"consolidation_type": "multi", "reason": "underutilized"}) + end := mkFamily(name, dto.MetricType_HISTOGRAM, single, multi) + out := deltaHistogram(name, nil, end) + if len(out) != 2 { + t.Fatalf("series count: got %d, want 2 (%v)", len(out), out) + } + singleKey := name + "{consolidation_type=single,reason=underutilized}" + multiKey := name + "{consolidation_type=multi,reason=underutilized}" + if _, ok := out[singleKey]; !ok { + t.Errorf("missing series key %q; got %v", singleKey, out) + } + if _, ok := out[multiKey]; !ok { + t.Errorf("missing series key %q; got %v", multiKey, out) + } + if out[singleKey].Count != 10 { + t.Errorf("single count: got %d, want 10", out[singleKey].Count) + } + if out[multiKey].Count != 5 { + t.Errorf("multi count: got %d, want 5", out[multiKey].Count) + } + if lbl := out[singleKey].Labels["consolidation_type"]; lbl != "single" { + t.Errorf("single labels.consolidation_type: got %q, want %q", lbl, "single") + } +} + +// Test 7. seriesKey is deterministic under label reordering. +func TestSeriesKey_StableSort(t *testing.T) { + name := "karpenter_consolidation_score" + a, av := "decision", "approved" + b, bv := "nodepool", "pool-a" + c, cv := "policy", "Balanced" + forward := []*dto.LabelPair{{Name: &a, Value: &av}, {Name: &b, Value: &bv}, {Name: &c, Value: &cv}} + reverse := []*dto.LabelPair{{Name: &c, Value: &cv}, {Name: &b, Value: &bv}, {Name: &a, Value: &av}} + if seriesKey(name, forward) != seriesKey(name, reverse) { + t.Errorf("seriesKey not stable under reorder: %q vs %q", seriesKey(name, forward), seriesKey(name, reverse)) + } + want := name + "{decision=approved,nodepool=pool-a,policy=Balanced}" + if got := seriesKey(name, forward); got != want { + t.Errorf("seriesKey format: got %q, want %q", got, want) + } +} + +// Test 8. Counter delta subtracts start value; reset falls back to end. +func TestDeltaCounter_DeltaAndReset(t *testing.T) { + name := "karpenter_voluntary_disruption_consolidation_timeouts_total" + lbl := map[string]string{"consolidation_type": "single"} + start := mkFamily(name, dto.MetricType_COUNTER, mkCounterMetric(3, lbl)) + end := mkFamily(name, dto.MetricType_COUNTER, mkCounterMetric(8, lbl)) + out := deltaCounter(name, start, end) + key := name + "{consolidation_type=single}" + if out[key] != 5 { + t.Errorf("counter delta: got %d, want 5", out[key]) + } + // Reset case: end < start -> take end as the delta. + resetV := 2.0 + end.Metric[0].Counter.Value = &resetV + out = deltaCounter(name, start, end) + if out[key] != 2 { + t.Errorf("counter reset: got %d, want 2", out[key]) + } +} + +// Test 9. deltaHistogram tolerates a missing metric family from either side. +func TestDeltaHistogram_MissingMetric(t *testing.T) { + out := deltaHistogram("karpenter_missing_metric", nil, nil) + if len(out) != 0 { + t.Errorf("missing metric: got %d series, want 0", len(out)) + } +} + +// Test 10. compactFamilies keeps only the target metric families. +func TestCompactFamilies(t *testing.T) { + families := map[string]*dto.MetricFamily{ + "karpenter_pods_scheduling_decision_duration_seconds": mkFamily("karpenter_pods_scheduling_decision_duration_seconds", dto.MetricType_HISTOGRAM), + "karpenter_voluntary_disruption_consolidation_timeouts_total": mkFamily("karpenter_voluntary_disruption_consolidation_timeouts_total", dto.MetricType_COUNTER), + "go_gc_duration_seconds": mkFamily("go_gc_duration_seconds", dto.MetricType_SUMMARY), + "process_open_fds": mkFamily("process_open_fds", dto.MetricType_GAUGE), + "workqueue_adds_total": mkFamily("workqueue_adds_total", dto.MetricType_COUNTER), + } + out := compactFamilies(families) + if _, ok := out["karpenter_pods_scheduling_decision_duration_seconds"]; !ok { + t.Errorf("compact dropped a target histogram") + } + if _, ok := out["karpenter_voluntary_disruption_consolidation_timeouts_total"]; !ok { + t.Errorf("compact dropped a target counter") + } + if _, ok := out["go_gc_duration_seconds"]; ok { + t.Errorf("compact retained a non-target family") + } + if len(out) != 2 { + t.Errorf("compact size: got %d, want 2", len(out)) + } +} diff --git a/test/pkg/environment/common/latency_sidecar.go b/test/pkg/environment/common/latency_sidecar.go new file mode 100644 index 0000000000..49123b7ad2 --- /dev/null +++ b/test/pkg/environment/common/latency_sidecar.go @@ -0,0 +1,64 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// LatencySidecar is the JSON schema written alongside a PerformanceReport +// when the LatencyHarness observed histogram or counter deltas. Offline +// analysis tools unmarshal into this type to pair a PerformanceReport with +// its latency companion; keep it stable across performance-suite tests so +// prior artifacts continue to load. +type LatencySidecar struct { + TestName string `json:"test_name"` + ConsolidationPolicy string `json:"consolidation_policy"` + Timestamp time.Time `json:"timestamp"` + LatencyStats map[string]HistogramStats `json:"latency_stats,omitempty"` + Counters map[string]uint64 `json:"counters,omitempty"` +} + +// WriteLatencySidecar writes sc to