diff --git a/helm/bundles/cortex-nova/templates/alerts.yaml b/helm/bundles/cortex-nova/templates/alerts.yaml index 09e9bae98..6c1a10250 100644 --- a/helm/bundles/cortex-nova/templates/alerts.yaml +++ b/helm/bundles/cortex-nova/templates/alerts.yaml @@ -342,7 +342,7 @@ spec: - alert: CortexNovaImagePropertiesHypervisorTypeUndetermined expr: | - sum by (pipeline, step) (rate(cortex_filter_weigher_pipeline_step_events_total{service="cortex-nova-metrics", event="image_properties_hv_type_undetermined"}[5m])) > 0.1 + sum by (pipeline, step, intent) (rate(cortex_filter_weigher_pipeline_step_events_total{service="cortex-nova-metrics", event="image_properties_hv_type_undetermined"}[5m])) > 0.1 for: 15m labels: context: scheduling @@ -356,11 +356,12 @@ spec: description: > The `filter_image_properties` step in pipeline `{{ "{{" }} $labels.pipeline {{ "}}" }}` is frequently unable to determine the hypervisor type from the image properties - of incoming scheduling requests. In this case the filter is skipped and all hosts - are returned, so kvm-only images may be placed on non-kvm hypervisors (or vice - versa). This may indicate that images are missing the expected hypervisor - type property, or that the property format has changed. Investigate the - image metadata of the affected requests. + of incoming scheduling requests for intent `{{ "{{" }} $labels.intent {{ "}}" }}`. + In this case the filter is skipped and all hosts are returned, so kvm-only + images may be placed on non-kvm hypervisors (or vice versa). This may indicate + that images are missing the expected hypervisor type property, or that the + property format has changed. Investigate the image metadata of the affected + requests. {{- if .Values.kvm.enabled }} - alert: CortexNovaDoesntFindValidKVMHosts diff --git a/internal/scheduling/lib/filter_weigher_pipeline_monitor.go b/internal/scheduling/lib/filter_weigher_pipeline_monitor.go index 341152322..785e18953 100644 --- a/internal/scheduling/lib/filter_weigher_pipeline_monitor.go +++ b/internal/scheduling/lib/filter_weigher_pipeline_monitor.go @@ -22,8 +22,6 @@ type FilterWeigherPipelineMonitor struct { stepReorderingsObserver *prometheus.HistogramVec // A histogram to observe the impact of the step on the hosts. stepImpactObserver *prometheus.HistogramVec - // A counter for named events reported by a step during its run. - stepEventCounter *prometheus.CounterVec // A histogram to measure how long the pipeline takes to run in total. pipelineRunTimer *prometheus.HistogramVec // A histogram to observe the number of hosts going into the scheduler pipeline. @@ -32,6 +30,10 @@ type FilterWeigherPipelineMonitor struct { hostNumberOutObserver *prometheus.HistogramVec // Counter for the number of requests processed by the scheduler. requestCounter *prometheus.CounterVec + + // stepEventCollector is a collector for named events reported by steps + // during their run. The collector supports a dynamic set of labels per event. + stepEventCollector *pipelineStepEventCollector } // Create a new scheduler monitor and register the necessary Prometheus metrics. @@ -66,10 +68,6 @@ func NewPipelineMonitor() FilterWeigherPipelineMonitor { Help: "Impact of the step on the hosts", Buckets: prometheus.ExponentialBucketsRange(0.01, 1000, 20), }, []string{"pipeline", "step", "stat", "unit"}), - stepEventCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "cortex_filter_weigher_pipeline_step_events_total", - Help: "Number of named events reported by a scheduler pipeline step", - }, []string{"pipeline", "step", "event"}), pipelineRunTimer: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "cortex_filter_weigher_pipeline_run_duration_seconds", Help: "Duration of scheduler pipeline run", @@ -89,6 +87,8 @@ func NewPipelineMonitor() FilterWeigherPipelineMonitor { Name: "cortex_filter_weigher_pipeline_requests_total", Help: "Total number of requests processed by the scheduler.", }, []string{"pipeline"}), + // Additional collectors. + stepEventCollector: newPipelineStepEventCollector(), } } @@ -122,12 +122,17 @@ func (m *FilterWeigherPipelineMonitor) observePipelineResult(request FilterWeigh } func (m *FilterWeigherPipelineMonitor) Describe(ch chan<- *prometheus.Desc) { + // stepEventCollector is intentionally not described here: its label names + // are dynamic and only known when events are recorded, so a fixed + // descriptor cannot be provided upfront. The remaining metrics are + // described normally so the registry can validate them at registration + // time. In practice the current prometheus client allows Collect to emit + // event metrics with label sets that differ from the described metrics. m.stepRunTimer.Describe(ch) m.stepHostWeight.Describe(ch) m.stepRemovedHostsObserver.Describe(ch) m.stepReorderingsObserver.Describe(ch) m.stepImpactObserver.Describe(ch) - m.stepEventCounter.Describe(ch) m.pipelineRunTimer.Describe(ch) m.hostNumberInObserver.Describe(ch) m.hostNumberOutObserver.Describe(ch) @@ -140,9 +145,10 @@ func (m *FilterWeigherPipelineMonitor) Collect(ch chan<- prometheus.Metric) { m.stepRemovedHostsObserver.Collect(ch) m.stepReorderingsObserver.Collect(ch) m.stepImpactObserver.Collect(ch) - m.stepEventCounter.Collect(ch) m.pipelineRunTimer.Collect(ch) m.hostNumberInObserver.Collect(ch) m.hostNumberOutObserver.Collect(ch) m.requestCounter.Collect(ch) + + m.stepEventCollector.Collect(ch) } diff --git a/internal/scheduling/lib/filter_weigher_pipeline_step_monitor.go b/internal/scheduling/lib/filter_weigher_pipeline_step_monitor.go index 2ad84e9fd..3fcdeebcc 100644 --- a/internal/scheduling/lib/filter_weigher_pipeline_step_monitor.go +++ b/internal/scheduling/lib/filter_weigher_pipeline_step_monitor.go @@ -12,6 +12,7 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/prometheus/client_golang/prometheus" ) @@ -36,8 +37,10 @@ type FilterWeigherPipelineStepMonitor[RequestType FilterWeigherPipelineRequest] stepReorderingsObserver *prometheus.HistogramVec // A metric measuring the impact of the step on the hosts. stepImpactObserver *prometheus.HistogramVec - // A counter for named events reported by the step during its run. - stepEventCounter *prometheus.CounterVec + + // stepEventCollector is a collector for named events reported by the + // step during its run. + stepEventCollector *pipelineStepEventCollector } // Schedule using the wrapped step and measure the time it takes. @@ -60,7 +63,7 @@ func monitorStep[RequestType FilterWeigherPipelineRequest](stepName string, m Fi removedHostsObserver: removedHostsObserver, stepReorderingsObserver: m.stepReorderingsObserver, stepImpactObserver: m.stepImpactObserver, - stepEventCounter: m.stepEventCounter, + stepEventCollector: m.stepEventCollector, } } @@ -87,11 +90,9 @@ func (s *FilterWeigherPipelineStepMonitor[RequestType]) RunWrapped( ) // Count named events reported by the step during its run. - if s.stepEventCounter != nil { + if s.stepEventCollector != nil { for _, event := range stepResult.Events { - s.stepEventCounter. - WithLabelValues(s.pipelineName, s.stepName, event). - Inc() + s.stepEventCollector.Record(s.pipelineName, s.stepName, event.Name, event.Labels) } } @@ -272,3 +273,106 @@ func impact(before, after []string, stats map[string]float64, topK int) (float64 return impact, nil } + +// pipelineStepEventCollector is a prometheus.Collector that counts named events +// reported by scheduler pipeline steps. It supports a dynamic set of labels per +// event: the label names are not fixed upfront but derived from the labels +// attached to each event. +type pipelineStepEventCollector struct { + sync.Mutex + // counts holds the event counter values keyed by a deterministic signature. + counts map[string]float64 + // events maps the signature to the full event context (pipeline, step, + // event name and dynamic labels). + events map[string]recordedStepEvent +} + +type recordedStepEvent struct { + pipeline string + step string + eventName string + labels map[string]string +} + +func newPipelineStepEventCollector() *pipelineStepEventCollector { + return &pipelineStepEventCollector{ + counts: make(map[string]float64), + events: make(map[string]recordedStepEvent), + } +} + +// reservedStepEventLabels are the fixed label names used by the event metric. +// Dynamic labels with these names are dropped because prometheus.Desc does not +// allow duplicate label names. +var reservedStepEventLabels = map[string]struct{}{ + "pipeline": {}, + "step": {}, + "event": {}, +} + +// Record increments the counter for the given event. The same event can be +// recorded multiple times; each call increments the matching counter. +// Dynamic labels that collide with the fixed label names (pipeline, step, +// event) are silently dropped to avoid panicking prometheus.MustNewConstMetric. +func (c *pipelineStepEventCollector) Record(pipeline, step, eventName string, labels map[string]string) { + c.Lock() + defer c.Unlock() + cleaned := make(map[string]string, len(labels)) + for k, v := range labels { + if _, reserved := reservedStepEventLabels[k]; reserved { + continue + } + cleaned[k] = v + } + key := stepEventKey(pipeline, step, eventName, cleaned) + if _, ok := c.counts[key]; !ok { + c.counts[key] = 0 + c.events[key] = recordedStepEvent{ + pipeline: pipeline, + step: step, + eventName: eventName, + labels: cleaned, + } + } + c.counts[key]++ +} + +// Describe is intentionally a no-op. Because the label names are dynamic and +// only known at collect time, we cannot describe the metric upfront. +func (c *pipelineStepEventCollector) Describe(ch chan<- *prometheus.Desc) { +} + +// Collect emits one metric per recorded event. Each metric shares the same +// fully-qualified name but may have a different set of labels depending on the +// labels provided by the step that reported the event. +func (c *pipelineStepEventCollector) Collect(ch chan<- prometheus.Metric) { + c.Lock() + defer c.Unlock() + for key, count := range c.counts { + ev := c.events[key] + labelNames := slices.Sorted(maps.Keys(ev.labels)) + labelValues := make([]string, len(labelNames)) + for i, name := range labelNames { + labelValues[i] = ev.labels[name] + } + desc := prometheus.NewDesc( + "cortex_filter_weigher_pipeline_step_events_total", + "Number of named events reported by a scheduler pipeline step", + append([]string{"pipeline", "step", "event"}, labelNames...), + nil, + ) + values := append([]string{ev.pipeline, ev.step, ev.eventName}, labelValues...) + ch <- prometheus.MustNewConstMetric(desc, prometheus.CounterValue, count, values...) + } +} + +// stepEventKey returns a deterministic signature for an event record. +func stepEventKey(pipeline, step, eventName string, labels map[string]string) string { + keys := slices.Sorted(maps.Keys(labels)) + parts := make([]string, 0, 3+2*len(keys)) + parts = append(parts, pipeline, step, eventName) + for _, k := range keys { + parts = append(parts, k, labels[k]) + } + return strings.Join(parts, "\x00") +} diff --git a/internal/scheduling/lib/filter_weigher_pipeline_step_monitor_test.go b/internal/scheduling/lib/filter_weigher_pipeline_step_monitor_test.go index 5ae402111..f17252d42 100644 --- a/internal/scheduling/lib/filter_weigher_pipeline_step_monitor_test.go +++ b/internal/scheduling/lib/filter_weigher_pipeline_step_monitor_test.go @@ -6,6 +6,7 @@ package lib import ( "log/slog" "os" + "strings" "testing" "github.com/prometheus/client_golang/prometheus" @@ -59,20 +60,19 @@ func TestStepMonitorRun(t *testing.T) { } func TestStepMonitorRunEvents(t *testing.T) { - stepEventCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "cortex_filter_weigher_pipeline_step_events_total", - Help: "Number of named events reported by a scheduler pipeline step", - }, []string{"pipeline", "step", "event"}) + stepEventCollector := newPipelineStepEventCollector() monitor := &FilterWeigherPipelineStepMonitor[mockFilterWeigherPipelineRequest]{ - stepName: "mock_step", - pipelineName: "mock_pipeline", - stepEventCounter: stepEventCounter, + stepName: "mock_step", + pipelineName: "mock_pipeline", + stepEventCollector: stepEventCollector, } step := &mockWeigher[mockFilterWeigherPipelineRequest]{ RunFunc: func(traceLog *slog.Logger, request mockFilterWeigherPipelineRequest) (*FilterWeigherPipelineStepResult, error) { return &FilterWeigherPipelineStepResult{ Activations: map[string]float64{"host1": 0.0}, - Events: []string{"hypervisor_type_undetermined"}, + Events: []FilterWeigherPipelineStepEvent{ + {Name: "hypervisor_type_undetermined"}, + }, }, nil }, } @@ -83,9 +83,52 @@ func TestStepMonitorRunEvents(t *testing.T) { if _, err := monitor.RunWrapped(slog.Default(), request, step); err != nil { t.Fatalf("Run() error = %v, want nil", err) } - got := testutil.ToFloat64(stepEventCounter.WithLabelValues("mock_pipeline", "mock_step", "hypervisor_type_undetermined")) - if got != 1 { - t.Errorf("stepEventCounter = %v, want 1", got) + if got := len(stepEventCollector.counts); got != 1 { + t.Fatalf("stepEventCollector.counts = %v, want 1", got) + } + for _, count := range stepEventCollector.counts { + if count != 1 { + t.Errorf("stepEventCollector count = %v, want 1", count) + } + } +} + +func TestStepMonitorRunEventsWithDynamicLabels(t *testing.T) { + stepEventCollector := newPipelineStepEventCollector() + monitor := &FilterWeigherPipelineStepMonitor[mockFilterWeigherPipelineRequest]{ + stepName: "mock_step", + pipelineName: "mock_pipeline", + stepEventCollector: stepEventCollector, + } + step := &mockWeigher[mockFilterWeigherPipelineRequest]{ + RunFunc: func(traceLog *slog.Logger, request mockFilterWeigherPipelineRequest) (*FilterWeigherPipelineStepResult, error) { + return &FilterWeigherPipelineStepResult{ + Activations: map[string]float64{"host1": 0.0}, + Events: []FilterWeigherPipelineStepEvent{ + { + Name: "hypervisor_type_undetermined", + Labels: map[string]string{"intent": "create"}, + }, + }, + }, nil + }, + } + request := mockFilterWeigherPipelineRequest{ + Hosts: []string{"host1"}, + Weights: map[string]float64{"host1": 0.0}, + } + if _, err := monitor.RunWrapped(slog.Default(), request, step); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + registry := prometheus.NewRegistry() + registry.MustRegister(stepEventCollector) + expected := ` + # HELP cortex_filter_weigher_pipeline_step_events_total Number of named events reported by a scheduler pipeline step + # TYPE cortex_filter_weigher_pipeline_step_events_total counter + cortex_filter_weigher_pipeline_step_events_total{event="hypervisor_type_undetermined",intent="create",pipeline="mock_pipeline",step="mock_step"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected)); err != nil { + t.Fatalf("GatherAndCompare() error = %v", err) } } @@ -180,3 +223,76 @@ func TestImpact(t *testing.T) { }) } } + +func TestPipelineStepEventCollector_DynamicLabels(t *testing.T) { + collector := newPipelineStepEventCollector() + collector.Record("pipeline", "step", "event_a", map[string]string{"intent": "create"}) + collector.Record("pipeline", "step", "event_a", map[string]string{"intent": "create"}) + collector.Record("pipeline", "step", "event_a", map[string]string{"intent": "resize"}) + collector.Record("pipeline", "step", "event_b", map[string]string{"foo": "bar"}) + + registry := prometheus.NewRegistry() + registry.MustRegister(collector) + + expected := ` + # HELP cortex_filter_weigher_pipeline_step_events_total Number of named events reported by a scheduler pipeline step + # TYPE cortex_filter_weigher_pipeline_step_events_total counter + cortex_filter_weigher_pipeline_step_events_total{event="event_a",intent="create",pipeline="pipeline",step="step"} 2 + cortex_filter_weigher_pipeline_step_events_total{event="event_a",intent="resize",pipeline="pipeline",step="step"} 1 + cortex_filter_weigher_pipeline_step_events_total{event="event_b",foo="bar",pipeline="pipeline",step="step"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected)); err != nil { + t.Fatalf("GatherAndCompare() error = %v", err) + } +} + +func TestPipelineStepEventCollector_NoLabels(t *testing.T) { + collector := newPipelineStepEventCollector() + collector.Record("pipeline", "step", "event_c", nil) + + registry := prometheus.NewRegistry() + registry.MustRegister(collector) + + expected := ` + # HELP cortex_filter_weigher_pipeline_step_events_total Number of named events reported by a scheduler pipeline step + # TYPE cortex_filter_weigher_pipeline_step_events_total counter + cortex_filter_weigher_pipeline_step_events_total{event="event_c",pipeline="pipeline",step="step"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected)); err != nil { + t.Fatalf("GatherAndCompare() error = %v", err) + } +} + +func TestPipelineStepEventCollector_DescribeIsNoOp(t *testing.T) { + collector := newPipelineStepEventCollector() + ch := make(chan *prometheus.Desc, 1) + collector.Describe(ch) + close(ch) + if len(ch) != 0 { + t.Errorf("expected Describe to send no descriptors, got %d", len(ch)) + } +} + +func TestPipelineStepEventCollector_DropsReservedLabels(t *testing.T) { + collector := newPipelineStepEventCollector() + // Reserved label names must be dropped so the emitted metric does not + // contain duplicate label names. + collector.Record("pipeline", "step", "event_d", map[string]string{ + "intent": "create", + "pipeline": "ignored", + "step": "ignored", + "event": "ignored", + }) + + registry := prometheus.NewRegistry() + registry.MustRegister(collector) + + expected := ` + # HELP cortex_filter_weigher_pipeline_step_events_total Number of named events reported by a scheduler pipeline step + # TYPE cortex_filter_weigher_pipeline_step_events_total counter + cortex_filter_weigher_pipeline_step_events_total{event="event_d",intent="create",pipeline="pipeline",step="step"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected)); err != nil { + t.Fatalf("GatherAndCompare() error = %v", err) + } +} diff --git a/internal/scheduling/lib/filter_weigher_pipeline_step_result.go b/internal/scheduling/lib/filter_weigher_pipeline_step_result.go index 8ccee7cf3..f7595cadd 100644 --- a/internal/scheduling/lib/filter_weigher_pipeline_step_result.go +++ b/internal/scheduling/lib/filter_weigher_pipeline_step_result.go @@ -27,9 +27,21 @@ type FilterWeigherPipelineStepResult struct { // Named events reported by the step during its run, e.g. // "hypervisor_type_undetermined". Each event is counted by the pipeline // monitor as cortex_filter_weigher_pipeline_step_events_total, labeled by - // pipeline, step and event. Use this to expose noteworthy step conditions - // (skipped filtering, missing data, ...) as Prometheus metrics. - Events []string + // pipeline, step, event and any additional labels provided by the step. + // Use this to expose noteworthy step conditions (skipped filtering, missing + // data, ...) as Prometheus metrics. + Events []FilterWeigherPipelineStepEvent +} + +// FilterWeigherPipelineStepEvent is a named event reported by a scheduling +// pipeline step. Additional labels can be attached to the event to enrich the +// exported Prometheus metric. +type FilterWeigherPipelineStepEvent struct { + // Name of the event, e.g. "image_properties_hv_type_undetermined". + Name string + // Additional labels to attach to the exported metric. Label names must be + // valid Prometheus label names. + Labels map[string]string } type FilterWeigherPipelineStepStatistics struct { diff --git a/internal/scheduling/nova/plugins/filters/filter_image_properties.go b/internal/scheduling/nova/plugins/filters/filter_image_properties.go index 2fe1ada44..dfc0d6446 100644 --- a/internal/scheduling/nova/plugins/filters/filter_image_properties.go +++ b/internal/scheduling/nova/plugins/filters/filter_image_properties.go @@ -22,9 +22,17 @@ type FilterImagePropertiesStep struct { func (s *FilterImagePropertiesStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + // Resolve the scheduling intent once and fall back to a readable default + // when it cannot be determined. + intent, intentErr := request.GetIntent() + intentLabel := "unknown" + if intentErr == nil { + intentLabel = string(intent) + } + // Apply this filter to all requests, unless we know from the request's // intent that image metadata is expected to not be set. - if intent, err := request.GetIntent(); err == nil { + if intentErr == nil { intentsExpectedToNotHaveImageMeta := []v1alpha1.SchedulingIntent{ // Cortex-internal intents in which scheduling requests are sent // mainly based on flavor-related metadata, independent of the @@ -50,8 +58,12 @@ func (s *FilterImagePropertiesStep) Run(traceLog *slog.Logger, request api.Exter traceLog.Warn("could not determine hypervisor type from image properties", "error", err) // Expose this event through the step monitor to alert on high-frequency - // occurrences of this situation. - result.Events = append(result.Events, "image_properties_hv_type_undetermined") + // occurrences of this situation. Include the request intent as a dynamic + // label so the alert can pinpoint which scheduling intents are affected. + result.Events = append(result.Events, lib.FilterWeigherPipelineStepEvent{ + Name: "image_properties_hv_type_undetermined", + Labels: map[string]string{"intent": intentLabel}, + }) return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_image_properties_test.go b/internal/scheduling/nova/plugins/filters/filter_image_properties_test.go index 64dd4bb26..8490b2b76 100644 --- a/internal/scheduling/nova/plugins/filters/filter_image_properties_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_image_properties_test.go @@ -8,6 +8,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -49,10 +50,11 @@ func TestFilterImagePropertiesStep_Run(t *testing.T) { } tests := []struct { - name string - request api.ExternalSchedulerRequest - expectedHosts []string - filteredHosts []string + name string + request api.ExternalSchedulerRequest + expectedHosts []string + filteredHosts []string + expectedEvents []lib.FilterWeigherPipelineStepEvent }{ { name: "kvm image keeps all hosts", @@ -77,12 +79,18 @@ func TestFilterImagePropertiesStep_Run(t *testing.T) { request: requestWith([]string{"host1", "host2", "host3"}, nil), expectedHosts: []string{"host1", "host2", "host3"}, filteredHosts: []string{}, + expectedEvents: []lib.FilterWeigherPipelineStepEvent{ + {Name: "image_properties_hv_type_undetermined", Labels: map[string]string{"intent": "unknown"}}, + }, }, { name: "unsupported hypervisor type keeps all hosts", request: requestWith([]string{"host1", "host2"}, map[string]any{"img_hv_type": "xen"}), expectedHosts: []string{"host1", "host2"}, filteredHosts: []string{}, + expectedEvents: []lib.FilterWeigherPipelineStepEvent{ + {Name: "image_properties_hv_type_undetermined", Labels: map[string]string{"intent": "unknown"}}, + }, }, { name: "empty host list", @@ -149,6 +157,27 @@ func TestFilterImagePropertiesStep_Run(t *testing.T) { if len(result.Activations) != len(tt.expectedHosts) { t.Errorf("expected %d hosts, got %d", len(tt.expectedHosts), len(result.Activations)) } + + if len(result.Events) != len(tt.expectedEvents) { + t.Errorf("expected %d events, got %d", len(tt.expectedEvents), len(result.Events)) + } + for i, expectedEvent := range tt.expectedEvents { + if i >= len(result.Events) { + break + } + gotEvent := result.Events[i] + if gotEvent.Name != expectedEvent.Name { + t.Errorf("expected event name %q, got %q", expectedEvent.Name, gotEvent.Name) + } + if len(gotEvent.Labels) != len(expectedEvent.Labels) { + t.Errorf("expected event labels %v, got %v", expectedEvent.Labels, gotEvent.Labels) + } + for k, v := range expectedEvent.Labels { + if gotEvent.Labels[k] != v { + t.Errorf("expected event label %q=%q, got %q", k, v, gotEvent.Labels[k]) + } + } + } }) } }