diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/ImmutableKeyValuePairs.java b/api/all/src/main/java/io/opentelemetry/api/internal/ImmutableKeyValuePairs.java index a2590633b83..4e6b19cee36 100644 --- a/api/all/src/main/java/io/opentelemetry/api/internal/ImmutableKeyValuePairs.java +++ b/api/all/src/main/java/io/opentelemetry/api/internal/ImmutableKeyValuePairs.java @@ -274,4 +274,13 @@ public String toString() { sb.append("}"); return sb.toString(); } + + /** + * Return the backing data array for these attributes. This is only exposed for internal use by + * opentelemetry authors. The contents of the array MUST NOT be modified. + */ + @SuppressWarnings("AvoidObjectArrays") + public Object[] getData() { + return data; + } } diff --git a/sdk/metrics/build.gradle.kts b/sdk/metrics/build.gradle.kts index ef2dd8244a5..0fffd03457e 100644 --- a/sdk/metrics/build.gradle.kts +++ b/sdk/metrics/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { testImplementation(project(":sdk:testing")) testImplementation("com.google.guava:guava") + testImplementation("com.google.guava:guava-testlib") jmh(project(":sdk:trace")) jmh(project(":sdk:testing")) diff --git a/sdk/metrics/src/jmh/java/io/opentelemetry/sdk/metrics/MetricAdviceBenchmark.java b/sdk/metrics/src/jmh/java/io/opentelemetry/sdk/metrics/MetricAdviceBenchmark.java new file mode 100644 index 00000000000..66c1a830607 --- /dev/null +++ b/sdk/metrics/src/jmh/java/io/opentelemetry/sdk/metrics/MetricAdviceBenchmark.java @@ -0,0 +1,273 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.metrics.ExtendedLongCounterBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode({Mode.AverageTime}) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 10, time = 1) +@Fork(1) +public class MetricAdviceBenchmark { + + static final AttributeKey HTTP_REQUEST_METHOD = + AttributeKey.stringKey("http.request.method"); + static final AttributeKey URL_PATH = AttributeKey.stringKey("url.path"); + static final AttributeKey URL_SCHEME = AttributeKey.stringKey("url.scheme"); + static final AttributeKey HTTP_RESPONSE_STATUS_CODE = + AttributeKey.longKey("http.response.status_code"); + static final AttributeKey HTTP_ROUTE = AttributeKey.stringKey("http.route"); + static final AttributeKey NETWORK_PROTOCOL_NAME = + AttributeKey.stringKey("network.protocol.name"); + static final AttributeKey SERVER_PORT = AttributeKey.longKey("server.port"); + static final AttributeKey URL_QUERY = AttributeKey.stringKey("url.query"); + static final AttributeKey CLIENT_ADDRESS = AttributeKey.stringKey("client.address"); + static final AttributeKey NETWORK_PEER_ADDRESS = + AttributeKey.stringKey("network.peer.address"); + static final AttributeKey NETWORK_PEER_PORT = AttributeKey.longKey("network.peer.port"); + static final AttributeKey NETWORK_PROTOCOL_VERSION = + AttributeKey.stringKey("network.protocol.version"); + static final AttributeKey SERVER_ADDRESS = AttributeKey.stringKey("server.address"); + static final AttributeKey USER_AGENT_ORIGINAL = + AttributeKey.stringKey("user_agent.original"); + + static final List> httpServerMetricAttributeKeys = + Arrays.asList( + HTTP_REQUEST_METHOD, + URL_SCHEME, + HTTP_RESPONSE_STATUS_CODE, + HTTP_ROUTE, + NETWORK_PROTOCOL_NAME, + SERVER_PORT, + NETWORK_PROTOCOL_VERSION, + SERVER_ADDRESS); + + static Attributes httpServerMetricAttributes() { + return Attributes.builder() + .put(HTTP_REQUEST_METHOD, "GET") + .put(URL_SCHEME, "http") + .put(HTTP_RESPONSE_STATUS_CODE, 200) + .put(HTTP_ROUTE, "/v1/users/{id}") + .put(NETWORK_PROTOCOL_NAME, "http") + .put(SERVER_PORT, 8080) + .put(NETWORK_PROTOCOL_VERSION, "1.1") + .put(SERVER_ADDRESS, "localhost") + .build(); + } + + static Attributes httpServerSpanAttributes() { + return Attributes.builder() + .put(HTTP_REQUEST_METHOD, "GET") + .put(URL_PATH, "/v1/users/123") + .put(URL_SCHEME, "http") + .put(HTTP_RESPONSE_STATUS_CODE, 200) + .put(HTTP_ROUTE, "/v1/users/{id}") + .put(NETWORK_PROTOCOL_NAME, "http") + .put(SERVER_PORT, 8080) + .put(URL_QUERY, "with=email") + .put(CLIENT_ADDRESS, "192.168.0.17") + .put(NETWORK_PEER_ADDRESS, "192.168.0.17") + .put(NETWORK_PEER_PORT, 11265) + .put(NETWORK_PROTOCOL_VERSION, "1.1") + .put(SERVER_ADDRESS, "localhost") + .put(USER_AGENT_ORIGINAL, "okhttp/1.27.2") + .build(); + } + + static final Attributes CACHED_HTTP_SERVER_SPAN_ATTRIBUTES = httpServerSpanAttributes(); + + @State(Scope.Benchmark) + public static class ThreadState { + + @Param InstrumentParam instrumentParam; + + SdkMeterProvider meterProvider; + + @Setup(Level.Iteration) + public void setup() { + meterProvider = + SdkMeterProvider.builder() + .registerMetricReader(InMemoryMetricReader.createDelta()) + .build(); + Meter meter = meterProvider.get("meter"); + instrumentParam.instrument().setup(meter); + } + + @TearDown + public void tearDown() { + meterProvider.shutdown().join(10, TimeUnit.SECONDS); + } + } + + @Benchmark + @Threads(1) + public void record(ThreadState threadState) { + threadState.instrumentParam.instrument().record(1); + } + + @SuppressWarnings("ImmutableEnumChecker") + public enum InstrumentParam { + /** + * Record HTTP span attributes without advice. This baseline shows the CPU and memory allocation + * independent of advice. + */ + NO_ADVICE_ALL_ATTRIBUTES( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")).build(); + } + + @Override + void record(long value) { + counter.add(value, httpServerSpanAttributes()); + } + }), + /** + * Record HTTP metric attributes without advice. This baseline shows the lower bound if + * attribute filtering was done in instrumentation instead of the metrics SDK with advice. It's + * not quite fair though because instrumentation would have to separately allocate attributes + * for spans and metrics, whereas with advice, we can manage to only allocate span attributes + * and a lightweight metrics attributes view derived from span attributes. + */ + NO_ADVICE_FILTERED_ATTRIBUTES( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")).build(); + } + + @Override + void record(long value) { + counter.add(value, httpServerMetricAttributes()); + } + }), + /** + * Record cached HTTP span attributes without advice. This baseline helps isolate the CPU and + * memory allocation for recording vs. creating attributes. + */ + NO_ADVICE_ALL_ATTRIBUTES_CACHED( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")).build(); + } + + @Override + void record(long value) { + counter.add(value, CACHED_HTTP_SERVER_SPAN_ATTRIBUTES); + } + }), + /** + * Record HTTP span attributes with advice filtering to HTTP metric attributes. This is meant to + * realistically demonstrate a typical HTTP server instrumentation scenario. + */ + ADVICE_ALL_ATTRIBUTES( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = + ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")) + .setAttributesAdvice(httpServerMetricAttributeKeys) + .build(); + } + + @Override + void record(long value) { + counter.add(value, httpServerSpanAttributes()); + } + }), + /** + * Record HTTP metric attributes with advice filtering to HTTP metric attributes. This + * demonstrates the overhead of advice when no attributes are filtered. + */ + ADVICE_FILTERED_ATTRIBUTES( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = + ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")) + .setAttributesAdvice(httpServerMetricAttributeKeys) + .build(); + } + + @Override + void record(long value) { + counter.add(value, httpServerMetricAttributes()); + } + }), + /** + * Record cached HTTP span attributes with advice filtering to HTTP metric attributes. This + * isolates the CPU and memory allocation for applying advice vs. creating attributes. + */ + ADVICE_ALL_ATTRIBUTES_CACHED( + new Instrument() { + private LongCounter counter; + + @Override + void setup(Meter meter) { + counter = + ((ExtendedLongCounterBuilder) meter.counterBuilder("counter")) + .setAttributesAdvice(httpServerMetricAttributeKeys) + .build(); + } + + @Override + void record(long value) { + counter.add(value, CACHED_HTTP_SERVER_SPAN_ATTRIBUTES); + } + }); + + private final Instrument instrument; + + InstrumentParam(Instrument instrument) { + this.instrument = instrument; + } + + Instrument instrument() { + return instrument; + } + } + + private abstract static class Instrument { + abstract void setup(Meter meter); + + abstract void record(long value); + } +} diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/AdviceAttributesProcessor.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/AdviceAttributesProcessor.java index b0373f25765..8b77b48e631 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/AdviceAttributesProcessor.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/AdviceAttributesProcessor.java @@ -7,7 +7,6 @@ import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.context.Context; import java.util.HashSet; import java.util.List; @@ -23,26 +22,7 @@ final class AdviceAttributesProcessor extends AttributesProcessor { @Override public Attributes process(Attributes incoming, Context context) { - // Exit early to avoid allocations if the incoming attributes do not have extra keys to be - // filtered - if (!hasExtraKeys(incoming)) { - return incoming; - } - AttributesBuilder builder = incoming.toBuilder(); - builder.removeIf(key -> !attributeKeys.contains(key)); - return builder.build(); - } - - /** Returns true if {@code attributes} has keys not contained in {@link #attributeKeys}. */ - private boolean hasExtraKeys(Attributes attributes) { - boolean[] result = {false}; - attributes.forEach( - (key, value) -> { - if (!result[0] && !attributeKeys.contains(key)) { - result[0] = true; - } - }); - return result[0]; + return FilteredAttributes.create(incoming, attributeKeys); } @Override diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributes.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributes.java new file mode 100644 index 00000000000..d71616c20e8 --- /dev/null +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributes.java @@ -0,0 +1,284 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.view; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.internal.ImmutableKeyValuePairs; +import java.util.BitSet; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.BiConsumer; +import javax.annotation.Nullable; + +/** + * Filtered attributes is a filtered view of a {@link ImmutableKeyValuePairs} backed {@link + * Attributes} instance. Rather than creating an entirely new attributes instance, it keeps track of + * which source attributes are excluded while implementing the {@link Attributes} interface. + * + *

Notably, the {@link FilteredAttributes#equals(Object)} and {@link + * FilteredAttributes#hashCode()} depend on comparison against other {@link FilteredAttributes} + * instances. This means that where {@link FilteredAttributes} is used for things like map keys, it + * must be used for all keys in that map. You cannot mix {@link Attributes} implementations. This is + * also true for the default attributes implementation. + */ +@SuppressWarnings("unchecked") +abstract class FilteredAttributes implements Attributes { + + // Backing source data from ImmutableKeyValuePairs.data. This array MUST NOT be mutated. + private final Object[] sourceData; + private final int hashcode; + private final int size; + + private FilteredAttributes(Object[] sourceData, int hashcode, int size) { + this.sourceData = sourceData; + this.hashcode = hashcode; + this.size = size; + } + + /** + * Create a {@link FilteredAttributes} instance. + * + * @param source the source attributes, which SHOULD be based on the standard {@link + * ImmutableKeyValuePairs}. If not, the source will first be converted to the standard + * implementation. + * @param includedKeys the set of attribute keys to include in the output. + */ + @SuppressWarnings("NullAway") + static Attributes create(Attributes source, Set> includedKeys) { + // Convert alternative implementations of Attributes to standard implementation. + // This is required for proper functioning of equals and hashcode. + if (!(source instanceof ImmutableKeyValuePairs)) { + source = convertToStandardImplementation(source); + } + if (!(source instanceof ImmutableKeyValuePairs)) { + throw new IllegalStateException( + "Expected ImmutableKeyValuePairs based implementation of Attributes. This is a programming error."); + } + // Compute filteredIndices (and filteredIndicesBitSet if needed) during initialization. Compute + // hashcode at the same time to avoid iteration later. + Object[] sourceData = ((ImmutableKeyValuePairs) source).getData(); + int filteredIndices = 0; + BitSet filteredIndicesBitSet = + source.size() > SmallFilteredAttributes.BITS_PER_INTEGER ? new BitSet(source.size()) : null; + int hashcode = 1; + int size = 0; + for (int i = 0; i < sourceData.length; i += 2) { + int filterIndex = i / 2; + // If the sourceData key isn't present in includedKeys, record the exclusion in + // filteredIndices or filteredIndicesBitSet (depending on size) + if (!includedKeys.contains(sourceData[i])) { + // Record + if (filteredIndicesBitSet != null) { + filteredIndicesBitSet.set(filterIndex); + } else { + filteredIndices = filteredIndices | (1 << filterIndex); + } + } else { // The key-value is included in the output, record in the hashcode and size. + hashcode = 31 * hashcode + sourceData[i].hashCode(); + hashcode = 31 * hashcode + sourceData[i + 1].hashCode(); + size++; + } + } + // If size is 0, short circuit and return Attributes.empty() + if (size == 0) { + return Attributes.empty(); + } + return filteredIndicesBitSet != null + ? new RegularFilteredAttributes(sourceData, hashcode, size, filteredIndicesBitSet) + : new SmallFilteredAttributes(sourceData, hashcode, size, filteredIndices); + } + + /** + * Implementation that relies on the source having less than {@link #BITS_PER_INTEGER} attributes, + * and storing entry filter status in the bits of an integer. + */ + private static class SmallFilteredAttributes extends FilteredAttributes { + + private static final int BITS_PER_INTEGER = 32; + + private final int filteredIndices; + + private SmallFilteredAttributes( + Object[] sourceData, int hashcode, int size, int filteredIndices) { + super(sourceData, hashcode, size); + this.filteredIndices = filteredIndices; + } + + @Override + boolean includeIndexInOutput(int sourceIndex) { + return (filteredIndices & (1 << (sourceIndex / 2))) == 0; + } + } + + /** + * Implementation that can handle attributes of arbitrary size by storing filter status in a + * {@link BitSet}. + */ + private static class RegularFilteredAttributes extends FilteredAttributes { + + private final BitSet bitSet; + + private RegularFilteredAttributes(Object[] sourceData, int hashcode, int size, BitSet bitSet) { + super(sourceData, hashcode, size); + this.bitSet = bitSet; + } + + @Override + boolean includeIndexInOutput(int sourceIndex) { + return !bitSet.get(sourceIndex / 2); + } + } + + private static Attributes convertToStandardImplementation(Attributes source) { + AttributesBuilder builder = Attributes.builder(); + source.forEach( + (key, value) -> putInBuilder(builder, (AttributeKey) key, value)); + return builder.build(); + } + + @Nullable + @Override + public T get(AttributeKey key) { + if (key == null) { + return null; + } + for (int i = 0; i < sourceData.length; i += 2) { + if (key.equals(sourceData[i]) && includeIndexInOutput(i)) { + return (T) sourceData[i + 1]; + } + } + return null; + } + + @Override + public void forEach(BiConsumer, ? super Object> consumer) { + for (int i = 0; i < sourceData.length; i += 2) { + if (includeIndexInOutput(i)) { + consumer.accept((AttributeKey) sourceData[i], sourceData[i + 1]); + } + } + } + + @Override + public int size() { + return size; + } + + @Override + public boolean isEmpty() { + // #create short circuits and returns Attributes.empty() if empty, so FilteredAttributes is + // never empty + return false; + } + + @Override + public Map, Object> asMap() { + Map, Object> result = new LinkedHashMap<>(size); + for (int i = 0; i < sourceData.length; i += 2) { + if (includeIndexInOutput(i)) { + result.put((AttributeKey) sourceData[i], sourceData[i + 1]); + } + } + return Collections.unmodifiableMap(result); + } + + @Override + public AttributesBuilder toBuilder() { + AttributesBuilder builder = Attributes.builder(); + for (int i = 0; i < sourceData.length; i += 2) { + if (includeIndexInOutput(i)) { + putInBuilder(builder, (AttributeKey) sourceData[i], sourceData[i + 1]); + } + } + return builder; + } + + private static void putInBuilder(AttributesBuilder builder, AttributeKey key, T value) { + builder.put(key, value); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + // We require other object to also be instances of FilteredAttributes. In other words, where one + // FilteredAttributes is used for a key in a map, it must be used for all the keys. Note, this + // same requirement exists for the default Attributes implementation - you can not mix + // implementations. + if (object == null || !(object instanceof FilteredAttributes)) { + return false; + } + + FilteredAttributes that = (FilteredAttributes) object; + // exit early if sizes are not equal + if (size() != that.size()) { + return false; + } + // Compare each non-filtered key / value pair from this to that. + // Depends on the entries from the backing ImmutableKeyValuePairs being sorted. + int thisIndex = 0; + int thatIndex = 0; + boolean thisDone; + boolean thatDone; + do { + thisDone = thisIndex >= this.sourceData.length; + thatDone = thatIndex >= that.sourceData.length; + // advance to next unfiltered key value pair for this and that + if (!thisDone && !this.includeIndexInOutput(thisIndex)) { + thisIndex += 2; + continue; + } + if (!thatDone && !that.includeIndexInOutput(thatIndex)) { + thatIndex += 2; + continue; + } + // if we're done iterating both this and that, we exit and return true since these are equal + if (thisDone && thatDone) { + break; + } + // if either this or that is done iterating, but not both, these are not equal + if (thisDone != thatDone) { + return false; + } + // if we make it here, both thisIndex and thatIndex within bounds and are included in the + // output. the current + // key and value and this and that must be equal for this and that to be equal. + if (!Objects.equals(this.sourceData[thisIndex], that.sourceData[thatIndex]) + || !Objects.equals(this.sourceData[thisIndex + 1], that.sourceData[thatIndex + 1])) { + return false; + } + thisIndex += 2; + thatIndex += 2; + } while (true); + // if we make it here without exiting early, all elements of this and that are equal + return true; + } + + @Override + public int hashCode() { + return hashcode; + } + + @Override + public String toString() { + StringJoiner joiner = new StringJoiner(",", "FilteredAttributes{", "}"); + for (int i = 0; i < sourceData.length; i += 2) { + if (includeIndexInOutput(i)) { + joiner.add(((AttributeKey) sourceData[i]).getKey() + "=" + sourceData[i + 1]); + } + } + return joiner.toString(); + } + + abstract boolean includeIndexInOutput(int sourceIndex); +} diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributesTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributesTest.java new file mode 100644 index 00000000000..b8bc0d10932 --- /dev/null +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/FilteredAttributesTest.java @@ -0,0 +1,192 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.view; + +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.testing.EqualsTester; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Unit tests for {@link FilteredAttributes}s. */ +@SuppressWarnings("rawtypes") +class FilteredAttributesTest { + + private static final AttributeKey KEY1 = stringKey("key1"); + private static final AttributeKey KEY2 = stringKey("key2"); + private static final AttributeKey KEY3 = stringKey("key3"); + private static final AttributeKey KEY4 = stringKey("key4"); + private static final AttributeKey KEY2_LONG = longKey("key2"); + private static final Set> ALL_KEYS = + ImmutableSet.of(KEY1, KEY2, KEY3, KEY4, KEY2_LONG); + private static final Attributes ALL_ATTRIBUTES = + Attributes.of(KEY1, "value1", KEY2, "value2", KEY2_LONG, 222L, KEY3, "value3"); + private static final Attributes FILTERED_ATTRIBUTES_ONE = + FilteredAttributes.create(ALL_ATTRIBUTES, ImmutableSet.of(KEY1)); + private static final Attributes FILTERED_ATTRIBUTES_TWO = + FilteredAttributes.create(ALL_ATTRIBUTES, ImmutableSet.of(KEY1, KEY2_LONG)); + private static final Attributes FILTERED_ATTRIBUTES_THREE = + FilteredAttributes.create(ALL_ATTRIBUTES, ImmutableSet.of(KEY1, KEY2_LONG, KEY3)); + private static final Attributes FILTERED_ATTRIBUTES_FOUR = + FilteredAttributes.create(ALL_ATTRIBUTES, ImmutableSet.of(KEY1, KEY2_LONG, KEY3, KEY4)); + private static final Attributes FILTERED_ATTRIBUTES_EMPTY_SOURCE = + FilteredAttributes.create(Attributes.empty(), ImmutableSet.of(KEY1)); + private static final Attributes FILTERED_ATTRIBUTES_EMPTY = + FilteredAttributes.create(ALL_ATTRIBUTES, Collections.emptySet()); + + @ParameterizedTest + @MethodSource("mapArgs") + void forEach(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + Map entriesSeen = new HashMap<>(); + filteredAttributes.forEach(entriesSeen::put); + assertThat(entriesSeen).isEqualTo(expectedMapEntries); + } + + @ParameterizedTest + @MethodSource("mapArgs") + void asMap(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + assertThat(filteredAttributes.asMap()).isEqualTo(expectedMapEntries); + } + + @ParameterizedTest + @MethodSource("mapArgs") + void size(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + assertThat(filteredAttributes.size()).isEqualTo(expectedMapEntries.size()); + } + + @ParameterizedTest + @MethodSource("mapArgs") + void isEmpty(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + assertThat(filteredAttributes.isEmpty()).isEqualTo(expectedMapEntries.isEmpty()); + } + + @ParameterizedTest + @MethodSource("mapArgs") + void get(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + for (AttributeKey key : ALL_KEYS) { + Object expectedValue = expectedMapEntries.get(key); + assertThat(filteredAttributes.get(key)).isEqualTo(expectedValue); + } + } + + @ParameterizedTest + @MethodSource("mapArgs") + void toBuilder(Attributes filteredAttributes, Map, Object> expectedMapEntries) { + Attributes attributes = filteredAttributes.toBuilder().build(); + assertThat(attributes.asMap()).isEqualTo(expectedMapEntries); + } + + private static Stream mapArgs() { + return Stream.of( + Arguments.of(FILTERED_ATTRIBUTES_ONE, ImmutableMap.of(KEY1, "value1")), + Arguments.of(FILTERED_ATTRIBUTES_TWO, ImmutableMap.of(KEY1, "value1", KEY2_LONG, 222L)), + Arguments.of( + FILTERED_ATTRIBUTES_THREE, + ImmutableMap.of(KEY1, "value1", KEY2_LONG, 222L, KEY3, "value3")), + Arguments.of( + FILTERED_ATTRIBUTES_FOUR, + ImmutableMap.of(KEY1, "value1", KEY2_LONG, 222L, KEY3, "value3")), + Arguments.of(FILTERED_ATTRIBUTES_EMPTY_SOURCE, Collections.emptyMap()), + Arguments.of(FILTERED_ATTRIBUTES_EMPTY, Collections.emptyMap())); + } + + @Test + void stringRepresentation() { + assertThat(FILTERED_ATTRIBUTES_ONE.toString()).isEqualTo("FilteredAttributes{key1=value1}"); + assertThat(FILTERED_ATTRIBUTES_TWO.toString()) + .isEqualTo("FilteredAttributes{key1=value1,key2=222}"); + assertThat(FILTERED_ATTRIBUTES_THREE.toString()) + .isEqualTo("FilteredAttributes{key1=value1,key2=222,key3=value3}"); + assertThat(FILTERED_ATTRIBUTES_FOUR.toString()) + .isEqualTo("FilteredAttributes{key1=value1,key2=222,key3=value3}"); + assertThat(FILTERED_ATTRIBUTES_EMPTY_SOURCE.toString()).isEqualTo("{}"); + assertThat(FILTERED_ATTRIBUTES_EMPTY.toString()).isEqualTo("{}"); + } + + /** + * Test behavior of attributes with more than the 32 limit of FilteredAttributes.filteredIndices. + */ + @RepeatedTest(10) + void largeAttributes() { + Set> allKeys = new HashSet<>(); + AttributesBuilder allAttributesBuilder = Attributes.builder(); + IntStream.range(0, 100) + .forEach( + i -> { + AttributeKey key = stringKey("key" + i); + allKeys.add(key); + allAttributesBuilder.put(key, "value" + i); + }); + Attributes allAttributes = allAttributesBuilder.build(); + + Attributes empty = FilteredAttributes.create(allAttributes, Collections.emptySet()); + assertThat(empty.size()).isEqualTo(0); + assertThat(empty.isEmpty()).isTrue(); + + Set> oneKey = allKeys.stream().limit(1).collect(Collectors.toSet()); + Attributes one = FilteredAttributes.create(allAttributes, oneKey); + assertThat(one.size()).isEqualTo(1); + assertThat(one.isEmpty()).isFalse(); + allKeys.stream() + .forEach( + key -> { + if (oneKey.contains(key)) { + assertThat(one.get(key)).isNotNull(); + } else { + assertThat(one.get(key)).isNull(); + } + }); + + Set> tenKeys = allKeys.stream().limit(10).collect(Collectors.toSet()); + Attributes ten = FilteredAttributes.create(allAttributes, tenKeys); + assertThat(ten.size()).isEqualTo(10); + assertThat(ten.isEmpty()).isFalse(); + allKeys.stream() + .forEach( + key -> { + if (tenKeys.contains(key)) { + assertThat(ten.get(key)).isNotNull(); + } else { + assertThat(ten.get(key)).isNull(); + } + }); + } + + @Test + void equalsAndHashCode() { + new EqualsTester() + .addEqualityGroup( + FILTERED_ATTRIBUTES_ONE, + FilteredAttributes.create(Attributes.of(KEY1, "value1"), Collections.singleton(KEY1)), + FilteredAttributes.create(Attributes.of(KEY1, "value1"), ImmutableSet.of(KEY1, KEY2)), + FilteredAttributes.create( + Attributes.of(KEY1, "value1", KEY2, "value2"), Collections.singleton(KEY1)), + FilteredAttributes.create( + Attributes.of(KEY1, "value1", KEY2_LONG, 222L), Collections.singleton(KEY1))) + .addEqualityGroup(FILTERED_ATTRIBUTES_TWO) + .addEqualityGroup(FILTERED_ATTRIBUTES_THREE, FILTERED_ATTRIBUTES_FOUR) + .addEqualityGroup(FILTERED_ATTRIBUTES_EMPTY, FILTERED_ATTRIBUTES_EMPTY_SOURCE) + .testEquals(); + } +}