diff --git a/CHANGELOG.md b/CHANGELOG.md index c02ec62168f..ac0bdc06da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` - `OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE` - `OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE` +- The `View` type and related `NewView` function to create a view according to the OpenTelemetry specification are added to `go.opentelemetry.io/otel/sdk/metric`. + These additions are replacements for the `View` type and `New` function from `go.opentelemetry.io/otel/sdk/metric/view`. (#3459) +- The `Instrument` and `InstrumentKind` type are added to `go.opentelemetry.io/otel/sdk/metric`. + These additions are replacements for the `Instrument` and `InstrumentKind` types from `go.opentelemetry.io/otel/sdk/metric/view`. (#3459) +- The `Stream` type is added to `go.opentelemetry.io/otel/sdk/metric` to define a metric data stream a view will produce. (#3459) ### Changed diff --git a/sdk/metric/instrument.go b/sdk/metric/instrument.go index 48440280e42..8009540e6b2 100644 --- a/sdk/metric/instrument.go +++ b/sdk/metric/instrument.go @@ -24,10 +24,133 @@ import ( "go.opentelemetry.io/otel/metric/instrument/syncfloat64" "go.opentelemetry.io/otel/metric/instrument/syncint64" "go.opentelemetry.io/otel/metric/unit" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/metric/aggregation" "go.opentelemetry.io/otel/sdk/metric/internal" "go.opentelemetry.io/otel/sdk/metric/metricdata" ) +var ( + zeroUnit unit.Unit + zeroInstrumentKind InstrumentKind + zeroScope instrumentation.Scope +) + +// InstrumentKind is the identifier of a group of instruments that all +// performing the same function. +type InstrumentKind uint8 + +const ( + // instrumentKindUndefined is an undefined instrument kind, it should not + // be used by any initialized type. + instrumentKindUndefined InstrumentKind = iota // nolint:deadcode,varcheck,unused + // InstrumentKindSyncCounter identifies a group of instruments that record + // increasing values synchronously with the code path they are measuring. + InstrumentKindSyncCounter + // InstrumentKindSyncUpDownCounter identifies a group of instruments that + // record increasing and decreasing values synchronously with the code path + // they are measuring. + InstrumentKindSyncUpDownCounter + // InstrumentKindSyncHistogram identifies a group of instruments that + // record a distribution of values synchronously with the code path they + // are measuring. + InstrumentKindSyncHistogram + // InstrumentKindAsyncCounter identifies a group of instruments that record + // increasing values in an asynchronous callback. + InstrumentKindAsyncCounter + // InstrumentKindAsyncUpDownCounter identifies a group of instruments that + // record increasing and decreasing values in an asynchronous callback. + InstrumentKindAsyncUpDownCounter + // InstrumentKindAsyncGauge identifies a group of instruments that record + // current values in an asynchronous callback. + InstrumentKindAsyncGauge +) + +type nonComparable [0]func() // nolint: unused // This is indeed used. + +// Instrument describes properties an instrument is created with. +type Instrument struct { + // Name is the human-readable identifier of the instrument. + Name string + // Description describes the purpose of the instrument. + Description string + // Kind defines the functional group of the instrument. + Kind InstrumentKind + // Unit is the unit of measurement recorded by the instrument. + Unit unit.Unit + // Scope identifies the instrumentation that created the instrument. + Scope instrumentation.Scope + + // Ensure forward compatibility if non-comparable fields need to be added. + nonComparable // nolint: unused +} + +// empty returns if all fields of i are their zero-value. +func (i Instrument) empty() bool { + return i.Name == "" && + i.Description == "" && + i.Kind == zeroInstrumentKind && + i.Unit == zeroUnit && + i.Scope == zeroScope +} + +// matches returns whether all the non-zero-value fields of i match the +// corresponding fields of other. If i is empty it will match all other, and +// true will always be returned. +func (i Instrument) matches(other Instrument) bool { + return i.matchesName(other) && + i.matchesDescription(other) && + i.matchesKind(other) && + i.matchesUnit(other) && + i.matchesScope(other) +} + +// matchesName returns true if the Name of i is "" or it equals the Name of +// other, otherwise false. +func (i Instrument) matchesName(other Instrument) bool { + return i.Name == "" || i.Name == other.Name +} + +// matchesDescription returns true if the Description of i is "" or it equals +// the Description of other, otherwise false. +func (i Instrument) matchesDescription(other Instrument) bool { + return i.Description == "" || i.Description == other.Description +} + +// matchesKind returns true if the Kind of i is its zero-value or it equals the +// Kind of other, otherwise false. +func (i Instrument) matchesKind(other Instrument) bool { + return i.Kind == zeroInstrumentKind || i.Kind == other.Kind +} + +// matchesUnit returns true if the Unit of i is its zero-value or it equals the +// Unit of other, otherwise false. +func (i Instrument) matchesUnit(other Instrument) bool { + return i.Unit == zeroUnit || i.Unit == other.Unit +} + +// matchesScope returns true if the Scope of i is its zero-value or it equals +// the Scope of other, otherwise false. +func (i Instrument) matchesScope(other Instrument) bool { + return (i.Scope.Name == "" || i.Scope.Name == other.Scope.Name) && + (i.Scope.Version == "" || i.Scope.Version == other.Scope.Version) && + (i.Scope.SchemaURL == "" || i.Scope.SchemaURL == other.Scope.SchemaURL) +} + +// Stream describes the stream of data an instrument produces. +type Stream struct { + // Name is the human-readable identifier of the stream. + Name string + // Description describes the purpose of the data. + Description string + // Unit is the unit of measurement recorded. + Unit unit.Unit + // Aggregation the stream uses for an instrument. + Aggregation aggregation.Aggregation + // AttributeFilter applied to all attributes recorded for an instrument. + AttributeFilter attribute.Filter +} + // instrumentID are the identifying properties of an instrument. type instrumentID struct { // Name is the name of the instrument. diff --git a/sdk/metric/pipeline_registry_test.go b/sdk/metric/pipeline_registry_test.go index ae3a2173f30..3f73637bf5f 100644 --- a/sdk/metric/pipeline_registry_test.go +++ b/sdk/metric/pipeline_registry_test.go @@ -374,6 +374,7 @@ func TestPipelineRegistryCreateAggregatorsIncompatibleInstrument(t *testing.T) { type logCounter struct { logr.LogSink + errN uint32 infoN uint32 } @@ -386,6 +387,15 @@ func (l *logCounter) InfoN() int { return int(atomic.SwapUint32(&l.infoN, 0)) } +func (l *logCounter) Error(err error, msg string, keysAndValues ...interface{}) { + atomic.AddUint32(&l.errN, 1) + l.LogSink.Error(err, msg, keysAndValues...) +} + +func (l *logCounter) ErrorN() int { + return int(atomic.SwapUint32(&l.errN, 0)) +} + func TestResolveAggregatorsDuplicateErrors(t *testing.T) { tLog := testr.NewWithOptions(t, testr.Options{Verbosity: 6}) l := &logCounter{LogSink: tLog.GetSink()} diff --git a/sdk/metric/view.go b/sdk/metric/view.go new file mode 100644 index 00000000000..b8fc363a0a7 --- /dev/null +++ b/sdk/metric/view.go @@ -0,0 +1,124 @@ +// Copyright The OpenTelemetry 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 metric // import "go.opentelemetry.io/otel/sdk/metric" + +import ( + "errors" + "regexp" + "strings" + + "go.opentelemetry.io/otel/internal/global" + "go.opentelemetry.io/otel/sdk/metric/aggregation" +) + +var ( + errMultiInst = errors.New("name replacement for multiple instruments") + + emptyView = func(Instrument) (Stream, bool) { return Stream{}, false } +) + +// View is an override to the default behavior of the SDK. It defines how data +// should be collected for certain instruments. It returns true and the exact +// Stream to use for matching Instruments. Otherwise, if the view does not +// match, false is returned. +type View func(Instrument) (Stream, bool) + +// NewView returns a View that applies the Stream mask for all instruments that +// match criteria. The returned View will only apply mask if all non-zero-value +// fields of criteria match the corresponding Instrument passed to the view. If +// no criteria are provided, all field of criteria are their zero-values, a +// view that matches no instruments is returned. If you need to match a +// zero-value field, create a View directly. +// +// The Name field of criteria supports wildcard pattern matching. The wildcard +// "*" is recognized as matching zero or more characters, and "?" is recognized +// as matching exactly one character. For example, a pattern of "*" will match +// all instrument names. +// +// The Stream mask only applies updates for non-zero-value fields. By default, +// the Instrument the View matches against will be use for the Name, +// Description, and Unit of the returned Stream and no Aggregation or +// AttributeFilter are set. All non-zero-value fields of mask are used instead +// of the default. If you need to zero out an Stream field returned from a +// View, create a View directly. +func NewView(criteria Instrument, mask Stream) View { + if criteria.empty() { + return emptyView + } + + var matchFunc func(Instrument) bool + if strings.ContainsAny(criteria.Name, "*?") { + if mask.Name != "" { + global.Error( + errMultiInst, "dropping view", + "criteria", criteria, + "mask", mask, + ) + return emptyView + } + + // Handle branching here in NewView instead of criteria.matches so + // criteria.matches remains inlinable for the simple case. + pattern := regexp.QuoteMeta(criteria.Name) + pattern = "^" + pattern + "$" + pattern = strings.ReplaceAll(pattern, `\?`, ".") + pattern = strings.ReplaceAll(pattern, `\*`, ".*") + re := regexp.MustCompile(pattern) + matchFunc = func(i Instrument) bool { + return re.MatchString(i.Name) && + criteria.matchesDescription(i) && + criteria.matchesKind(i) && + criteria.matchesUnit(i) && + criteria.matchesScope(i) + } + } else { + matchFunc = criteria.matches + } + + var agg aggregation.Aggregation + if mask.Aggregation != nil { + agg = mask.Aggregation.Copy() + if err := agg.Err(); err != nil { + global.Error( + err, "not using aggregation with view", + "criteria", criteria, + "mask", mask, + ) + agg = nil + } + } + + return func(i Instrument) (Stream, bool) { + if matchFunc(i) { + return Stream{ + Name: nonZero(mask.Name, i.Name), + Description: nonZero(mask.Description, i.Description), + Unit: nonZero(mask.Unit, i.Unit), + Aggregation: agg, + AttributeFilter: mask.AttributeFilter, + }, true + } + return Stream{}, false + } +} + +// nonZero returns v if it is non-zero-valued, otherwise alt. +func nonZero[T comparable](v, alt T) T { + var zero T + if v != zero { + return v + } + return alt +} diff --git a/sdk/metric/view_test.go b/sdk/metric/view_test.go new file mode 100644 index 00000000000..082b0106ebd --- /dev/null +++ b/sdk/metric/view_test.go @@ -0,0 +1,471 @@ +// Copyright The OpenTelemetry 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 metric // import "go.opentelemetry.io/otel/sdk/metric" + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/unit" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/metric/aggregation" +) + +var ( + schemaURL = "https://opentelemetry.io/schemas/1.0.0" + completeIP = Instrument{ + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: instrumentation.Scope{ + Name: "TestNewViewMatch", + Version: "v0.1.0", + SchemaURL: schemaURL, + }, + } +) + +func scope(name, ver, url string) instrumentation.Scope { + return instrumentation.Scope{Name: name, Version: ver, SchemaURL: url} +} + +func testNewViewMatchName() func(t *testing.T) { + tests := []struct { + name string + criteria string + match []string + notMatch []string + }{ + { + name: "Exact", + criteria: "foo", + match: []string{"foo"}, + notMatch: []string{"", "bar", "foobar", "barfoo", "ffooo"}, + }, + { + name: "Wildcard/*", + criteria: "*", + match: []string{"", "foo", "foobar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Front?", + criteria: "?oo", + match: []string{"foo", "1oo"}, + notMatch: []string{"", "bar", "foobar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Back?", + criteria: "fo?", + match: []string{"foo", "fo1"}, + notMatch: []string{"", "bar", "foobar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Front*", + criteria: "*foo", + match: []string{"foo", "123foo", "barfoo"}, + notMatch: []string{"", "bar", "foobar", "barfoobaz"}, + }, + { + name: "Wildcard/Back*", + criteria: "foo*", + match: []string{"foo", "foo1", "foobar"}, + notMatch: []string{"", "bar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/FrontBack*", + criteria: "*foo*", + match: []string{"foo", "foo1", "1foo", "1foo1", "foobar", "barfoobaz"}, + notMatch: []string{"", "bar"}, + }, + { + name: "Wildcard/Front**", + criteria: "**foo", + match: []string{"foo", "123foo", "barfoo", "afoo"}, + notMatch: []string{"", "bar", "foobar", "barfoobaz"}, + }, + { + name: "Wildcard/Back**", + criteria: "foo**", + match: []string{"foo", "foo1", "fooa", "foobar"}, + notMatch: []string{"", "bar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Front*?", + criteria: "*?oo", + match: []string{"foo", "123foo", "barfoo", "afoo"}, + notMatch: []string{"", "fo", "bar", "foobar", "barfoobaz"}, + }, + { + name: "Wildcard/Back*?", + criteria: "fo*?", + match: []string{"foo", "foo1", "fooa", "foobar"}, + notMatch: []string{"", "bar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Front?*", + criteria: "?*oo", + match: []string{"foo", "123foo", "barfoo", "afoo"}, + notMatch: []string{"", "oo", "fo", "bar", "foobar", "barfoobaz"}, + }, + { + name: "Wildcard/Back?*", + criteria: "fo?*", + match: []string{"foo", "foo1", "fooa", "foobar"}, + notMatch: []string{"", "fo", "bar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Middle*", + criteria: "f*o", + match: []string{"fo", "foo", "fooo", "fo12baro"}, + notMatch: []string{"", "bar", "barfoo", "barfoobaz"}, + }, + { + name: "Wildcard/Middle?", + criteria: "f?o", + match: []string{"foo", "f1o"}, + notMatch: []string{"", "fo", "fooo", "fo12baro", "bar"}, + }, + { + name: "Wildcard/MetaCharacters", + criteria: "*.+()|[]{}^$-_?", + match: []string{"aa.+()|[]{}^$-_b", ".+()|[]{}^$-_b"}, + notMatch: []string{"", "foo", ".+()|[]{}^$-_"}, + }, + } + + return func(t *testing.T) { + for _, test := range tests { + v := NewView(Instrument{Name: test.criteria}, Stream{}) + t.Run(test.name, func(t *testing.T) { + for _, n := range test.match { + _, matches := v(Instrument{Name: n}) + assert.Truef(t, matches, "%s does not match %s", test.criteria, n) + } + for _, n := range test.notMatch { + _, matches := v(Instrument{Name: n}) + assert.Falsef(t, matches, "%s matches %s", test.criteria, n) + } + }) + } + } +} + +func TestNewViewMatch(t *testing.T) { + // Avoid boilerplate for name match testing. + t.Run("Name", testNewViewMatchName()) + + tests := []struct { + name string + criteria Instrument + matches []Instrument + notMatches []Instrument + }{ + { + name: "Empty", + notMatches: []Instrument{{}, {Name: "foo"}, completeIP}, + }, + { + name: "Description", + criteria: Instrument{Description: "foo desc"}, + matches: []Instrument{{Description: "foo desc"}, completeIP}, + notMatches: []Instrument{{}, {Description: "foo"}, {Description: "desc"}}, + }, + { + name: "Kind", + criteria: Instrument{Kind: InstrumentKindSyncCounter}, + matches: []Instrument{{Kind: InstrumentKindSyncCounter}, completeIP}, + notMatches: []Instrument{ + {}, + {Kind: InstrumentKindSyncUpDownCounter}, + {Kind: InstrumentKindSyncHistogram}, + {Kind: InstrumentKindAsyncCounter}, + {Kind: InstrumentKindAsyncUpDownCounter}, + {Kind: InstrumentKindAsyncGauge}, + }, + }, + { + name: "Unit", + criteria: Instrument{Unit: unit.Bytes}, + matches: []Instrument{{Unit: unit.Bytes}, completeIP}, + notMatches: []Instrument{ + {}, + {Unit: unit.Dimensionless}, + {Unit: unit.Unit("K")}, + }, + }, + { + name: "ScopeName", + criteria: Instrument{Scope: scope("TestNewViewMatch", "", "")}, + matches: []Instrument{ + {Scope: scope("TestNewViewMatch", "", "")}, + completeIP, + }, + notMatches: []Instrument{ + {}, + {Scope: scope("PrefixTestNewViewMatch", "", "")}, + {Scope: scope("TestNewViewMatchSuffix", "", "")}, + {Scope: scope("alt", "", "")}, + }, + }, + { + name: "ScopeVersion", + criteria: Instrument{Scope: scope("", "v0.1.0", "")}, + matches: []Instrument{ + {Scope: scope("", "v0.1.0", "")}, + completeIP, + }, + notMatches: []Instrument{ + {}, + {Scope: scope("", "v0.1.0-RC1", "")}, + {Scope: scope("", "v0.1.1", "")}, + }, + }, + { + name: "ScopeSchemaURL", + criteria: Instrument{Scope: scope("", "", schemaURL)}, + matches: []Instrument{ + {Scope: scope("", "", schemaURL)}, + completeIP, + }, + notMatches: []Instrument{ + {}, + {Scope: scope("", "", schemaURL+"/path")}, + {Scope: scope("", "", "https://go.dev")}, + }, + }, + { + name: "Scope", + criteria: Instrument{Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL)}, + matches: []Instrument{ + {Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL)}, + completeIP, + }, + notMatches: []Instrument{ + {}, + {Scope: scope("CompleteMisMatch", "v0.2.0", "https://go.dev")}, + {Scope: scope("NameMisMatch", "v0.1.0", schemaURL)}, + }, + }, + { + name: "Complete", + criteria: completeIP, + matches: []Instrument{completeIP}, + notMatches: []Instrument{ + {}, + {Name: "foo"}, + { + Name: "Wrong Name", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL), + }, + { + Name: "foo", + Description: "Wrong Description", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL), + }, + { + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindAsyncUpDownCounter, + Unit: unit.Bytes, + Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL), + }, + { + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Dimensionless, + Scope: scope("TestNewViewMatch", "v0.1.0", schemaURL), + }, + { + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: scope("Wrong Scope Name", "v0.1.0", schemaURL), + }, + { + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: scope("TestNewViewMatch", "v1.4.3", schemaURL), + }, + { + Name: "foo", + Description: "foo desc", + Kind: InstrumentKindSyncCounter, + Unit: unit.Bytes, + Scope: scope("TestNewViewMatch", "v0.1.0", "https://go.dev"), + }, + }, + }, + } + + for _, test := range tests { + v := NewView(test.criteria, Stream{}) + t.Run(test.name, func(t *testing.T) { + for _, instrument := range test.matches { + _, matches := v(instrument) + assert.Truef(t, matches, "view does not match %#v", instrument) + } + + for _, instrument := range test.notMatches { + _, matches := v(instrument) + assert.Falsef(t, matches, "view matches %#v", instrument) + } + }) + } +} + +func TestNewViewReplace(t *testing.T) { + alt := "alternative value" + tests := []struct { + name string + mask Stream + want func(Instrument) Stream + }{ + { + name: "Nothing", + want: func(i Instrument) Stream { + return Stream{ + Name: i.Name, + Description: i.Description, + Unit: i.Unit, + } + }, + }, + { + name: "Name", + mask: Stream{Name: alt}, + want: func(i Instrument) Stream { + return Stream{ + Name: alt, + Description: i.Description, + Unit: i.Unit, + } + }, + }, + { + name: "Description", + mask: Stream{Description: alt}, + want: func(i Instrument) Stream { + return Stream{ + Name: i.Name, + Description: alt, + Unit: i.Unit, + } + }, + }, + { + name: "Unit", + mask: Stream{Unit: unit.Dimensionless}, + want: func(i Instrument) Stream { + return Stream{ + Name: i.Name, + Description: i.Description, + Unit: unit.Dimensionless, + } + }, + }, + { + name: "Aggregation", + mask: Stream{Aggregation: aggregation.LastValue{}}, + want: func(i Instrument) Stream { + return Stream{ + Name: i.Name, + Description: i.Description, + Unit: i.Unit, + Aggregation: aggregation.LastValue{}, + } + }, + }, + { + name: "Complete", + mask: Stream{ + Name: alt, + Description: alt, + Unit: unit.Dimensionless, + Aggregation: aggregation.LastValue{}, + }, + want: func(i Instrument) Stream { + return Stream{ + Name: alt, + Description: alt, + Unit: unit.Dimensionless, + Aggregation: aggregation.LastValue{}, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, match := NewView(completeIP, test.mask)(completeIP) + require.True(t, match, "view did not match exact criteria") + assert.Equal(t, test.want(completeIP), got) + }) + } + + // Go does not allow for the comparison of function values, even their + // addresses. Therefore, the AttributeFilter field needs an alternative + // testing strategy. + t.Run("AttributeFilter", func(t *testing.T) { + allowed := attribute.String("key", "val") + filter := func(kv attribute.KeyValue) bool { + return kv == allowed + } + mask := Stream{AttributeFilter: filter} + got, match := NewView(completeIP, mask)(completeIP) + require.True(t, match, "view did not match exact criteria") + require.NotNil(t, got.AttributeFilter, "AttributeFilter not set") + assert.True(t, got.AttributeFilter(allowed), "wrong AttributeFilter") + other := attribute.String("key", "other val") + assert.False(t, got.AttributeFilter(other), "wrong AttributeFilter") + }) +} + +type badAgg struct { + aggregation.Aggregation + err error +} + +func (a badAgg) Copy() aggregation.Aggregation { return a } + +func (a badAgg) Err() error { return a.err } + +func TestNewViewAggregationErrorLogged(t *testing.T) { + tLog := testr.NewWithOptions(t, testr.Options{Verbosity: 6}) + l := &logCounter{LogSink: tLog.GetSink()} + otel.SetLogger(logr.New(l)) + + agg := badAgg{err: assert.AnError} + mask := Stream{Aggregation: agg} + got, match := NewView(completeIP, mask)(completeIP) + require.True(t, match, "view did not match exact criteria") + assert.Nil(t, got.Aggregation, "erroring aggregation used") + assert.Equal(t, 1, l.ErrorN()) +}