diff --git a/injection/sharedmain/main.go b/injection/sharedmain/main.go index 50d5f4c798..a44fde056d 100644 --- a/injection/sharedmain/main.go +++ b/injection/sharedmain/main.go @@ -399,10 +399,15 @@ func SetupObservabilityOrDie( resource := resource.Default(component) + views := OTelViews(ctx) + if denyList := cfg.Metrics.AttributesDenyList(); len(denyList) > 0 { + views = append(views, metrics.MetricAttributesDenyFilter(denyList)) + } + meterProvider, err := metrics.NewMeterProvider( ctx, cfg.Metrics, - metric.WithView(OTelViews(ctx)...), + metric.WithView(views...), metric.WithResource(resource), ) if err != nil { diff --git a/observability/metrics/config.go b/observability/metrics/config.go index dc911c2e6d..6cb742aa8f 100644 --- a/observability/metrics/config.go +++ b/observability/metrics/config.go @@ -18,6 +18,7 @@ package metrics import ( "fmt" + "strings" "time" configmap "knative.dev/pkg/configmap/parser" @@ -38,6 +39,28 @@ type Config struct { Protocol string `json:"protocol,omitempty"` Endpoint string `json:"endpoint,omitempty"` ExportInterval time.Duration `json:"exportInterval,omitempty"` + + // AttributesDeny is a comma-separated list of metric attribute keys to + // filter out from all instruments (e.g. "cloudevents.type,messaging.destination.name"). + // Stored as a string rather than []string to keep Config comparable, + // which is relied upon by downstream consumers. Use AttributesDenyList() + // to get the parsed list. + AttributesDeny string `json:"attributesDeny,omitempty"` +} + +// AttributesDenyList returns the deny list parsed into individual keys. +func (c *Config) AttributesDenyList() []string { + if c.AttributesDeny == "" { + return nil + } + parts := strings.Split(c.AttributesDeny, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + result = append(result, t) + } + } + return result } func (c *Config) Validate() error { @@ -79,6 +102,7 @@ func NewFromMapWithPrefix(prefix string, m map[string]string) (Config, error) { c := DefaultConfig() err := configmap.Parse(m, + configmap.As(prefix+"metrics-attributes-deny", &c.AttributesDeny), configmap.As(prefix+"metrics-protocol", &c.Protocol), configmap.As(prefix+"metrics-endpoint", &c.Endpoint), configmap.As(prefix+"metrics-export-interval", &c.ExportInterval), diff --git a/observability/metrics/config_test.go b/observability/metrics/config_test.go index 944cc4d7a8..9403b97b5a 100644 --- a/observability/metrics/config_test.go +++ b/observability/metrics/config_test.go @@ -66,11 +66,38 @@ func TestNewFromMapBadInput(t *testing.T) { } } +func TestNewFromMapAttributesDeny(t *testing.T) { + got, err := NewFromMap(map[string]string{ + "metrics-attributes-deny": "cloudevents.type, messaging.destination.name", + }) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []string{"cloudevents.type", "messaging.destination.name"} + if diff := cmp.Diff(want, got.AttributesDenyList()); diff != "" { + t.Error("unexpected diff (-want +got): ", diff) + } +} + +func TestNewFromMapAttributesDenyEmpty(t *testing.T) { + got, err := NewFromMap(nil) + if err != nil { + t.Fatal("unexpected error:", err) + } + + if got.AttributesDenyList() != nil { + t.Errorf("expected nil deny list, got %v", got.AttributesDenyList()) + } +} + func TestNewFromMapWithPrefix(t *testing.T) { got, err := NewFromMapWithPrefix("request-", map[string]string{ "request-metrics-protocol": ProtocolGRPC, "request-metrics-endpoint": "https://blah.example.com", "request-metrics-export-interval": "15s", + "request-metrics-attributes-deny": "cloudevents.type", + "metrics-attributes-deny": "should.be.ignored", }) if err != nil { t.Error("unexpected error", err) @@ -80,6 +107,7 @@ func TestNewFromMapWithPrefix(t *testing.T) { Protocol: ProtocolGRPC, Endpoint: "https://blah.example.com", ExportInterval: 15 * time.Second, + AttributesDeny: "cloudevents.type", } if diff := cmp.Diff(want, got); diff != "" { diff --git a/observability/metrics/view.go b/observability/metrics/view.go new file mode 100644 index 0000000000..00406e126b --- /dev/null +++ b/observability/metrics/view.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 The Knative 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 metrics + +import ( + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/metric" +) + +// MetricAttributesDenyFilter returns a View that strips the given attribute +// keys from every instrument. +func MetricAttributesDenyFilter(denyList []string) metric.View { + keys := make([]attribute.Key, len(denyList)) + for i, k := range denyList { + keys[i] = attribute.Key(k) + } + return metric.NewView( + metric.Instrument{Name: "*"}, + metric.Stream{ + AttributeFilter: attribute.NewDenyKeysFilter(keys...), + }, + ) +} diff --git a/observability/metrics/view_test.go b/observability/metrics/view_test.go new file mode 100644 index 0000000000..6c270c9f3f --- /dev/null +++ b/observability/metrics/view_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 The Knative 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 metrics + +import ( + "testing" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/metric" +) + +func TestMetricAttributesDenyFilter(t *testing.T) { + view := MetricAttributesDenyFilter([]string{"cloudevents.type", "messaging.destination.name"}) + + stream, ok := view(metric.Instrument{Name: "some.metric"}) + if !ok { + t.Fatal("view should match all instruments") + } + if stream.AttributeFilter == nil { + t.Fatal("expected non-nil attribute filter") + } + + denied := []attribute.KeyValue{ + attribute.String("cloudevents.type", "com.example.event"), + attribute.String("messaging.destination.name", "my-destination"), + } + for _, kv := range denied { + if stream.AttributeFilter(kv) { + t.Errorf("attribute %s should be denied", kv.Key) + } + } + + allowed := []attribute.KeyValue{ + attribute.String("messaging.system", "knative"), + attribute.Int("http.response.status_code", 200), + } + for _, kv := range allowed { + if !stream.AttributeFilter(kv) { + t.Errorf("attribute %s should be allowed", kv.Key) + } + } +} + +func TestMetricAttributesDenyFilterMatchesAllInstruments(t *testing.T) { + view := MetricAttributesDenyFilter([]string{"cloudevents.type"}) + + instruments := []string{ + "kn.eventing.dispatch.duration", + "http.server.request.duration", + "custom.metric", + } + for _, name := range instruments { + if _, ok := view(metric.Instrument{Name: name}); !ok { + t.Errorf("view should match instrument %s", name) + } + } +}