From 21ae409f99ca2a13989609f5be6c930b3c7b3aee Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:14:05 -0500 Subject: [PATCH 1/6] Add attribute limits to opentelemetry-api --- .../common/ArrayBackedAttributesBuilder.java | 109 ++++++++++++- .../api/common/AttributeLimits.java | 74 +++++++++ .../api/common/AttributeLimitsBuilder.java | 52 ++++++ .../opentelemetry/api/common/Attributes.java | 15 ++ .../api/internal/AttributeLengthLimits.java | 154 ++++++++++++++++++ .../api/common/AttributeLimitsTest.java | 67 ++++++++ .../common/BoundedAttributesBuilderTest.java | 102 ++++++++++++ .../current_vs_latest/opentelemetry-api.txt | 18 +- .../sdk/common/internal/AttributeUtil.java | 144 ++-------------- .../sdk/common/internal/AttributesMap.java | 128 --------------- .../common/internal/AttributesMapTest.java | 25 --- .../sdk/logs/SdkLogRecordBuilder.java | 18 +- .../sdk/logs/SdkReadWriteLogRecord.java | 36 ++-- .../sdk/logs/ReadWriteLogRecordTest.java | 7 +- .../io/opentelemetry/sdk/trace/SdkSpan.java | 73 ++++++--- .../sdk/trace/SdkSpanBuilder.java | 30 ++-- .../sdk/trace/SdkSpanBuilderTest.java | 2 +- .../opentelemetry/sdk/trace/SdkSpanTest.java | 42 +++-- 18 files changed, 728 insertions(+), 368 deletions(-) create mode 100644 api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java create mode 100644 api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java create mode 100644 api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java create mode 100644 api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java create mode 100644 api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java delete mode 100644 sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java delete mode 100644 sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java diff --git a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java index 834394c97e3..7f21751d2f7 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java @@ -14,6 +14,7 @@ import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; import static io.opentelemetry.api.common.AttributeKey.stringKey; +import io.opentelemetry.api.internal.AttributeLengthLimits; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -23,22 +24,68 @@ class ArrayBackedAttributesBuilder implements AttributesBuilder { private final List data; + /** Max number of unique entries. {@link Integer#MAX_VALUE} means unlimited. */ + private final int capacity; + + /** + * Max length of string / string-array values. {@link Integer#MAX_VALUE} means unlimited. Only + * consulted on the limited put path. + */ + private final int lengthLimit; + + /** Count of non-null entries currently stored (excludes null holes from remove). */ + private int size; + + /** + * Cached {@link #build()} result, invalidated by any mutation. Speeds up repeated read-through + * calls (e.g. {@code AttributesBuilder}-as-live-view for SDK span attributes). Only maintained in + * the limited configuration since the unlimited path defers dedup to {@link #build()} and has no + * need for a live view. + */ + @Nullable private Attributes cachedBuild; + ArrayBackedAttributesBuilder() { - data = new ArrayList<>(); + this(new ArrayList<>(), Integer.MAX_VALUE, Integer.MAX_VALUE, 0); } ArrayBackedAttributesBuilder(List data) { + this(data, Integer.MAX_VALUE, Integer.MAX_VALUE, data.size() / 2); + } + + ArrayBackedAttributesBuilder(AttributeLimits limits) { + this(new ArrayList<>(), limits.getCapacity(), limits.getLengthLimit(), 0); + } + + private ArrayBackedAttributesBuilder( + List data, int capacity, int lengthLimit, int initialSize) { this.data = data; + this.capacity = capacity; + this.lengthLimit = lengthLimit; + this.size = initialSize; + } + + private boolean isLimited() { + return capacity != Integer.MAX_VALUE || lengthLimit != Integer.MAX_VALUE; } @Override public Attributes build() { + Attributes cached = cachedBuild; + if (cached != null) { + return cached; + } + Attributes result; // If only one key-value pair AND the entry hasn't been set to null (by #remove(AttributeKey) // or #removeIf(Predicate>)), then we can bypass sorting and filtering if (data.size() == 2 && data.get(0) != null) { - return new ArrayBackedAttributes(data.toArray()); + result = new ArrayBackedAttributes(data.toArray()); + } else { + result = ArrayBackedAttributes.sortAndFilterToAttributes(data.toArray()); + } + if (isLimited()) { + cachedBuild = result; } - return ArrayBackedAttributes.sortAndFilterToAttributes(data.toArray()); + return result; } @Override @@ -55,11 +102,55 @@ public AttributesBuilder put(AttributeKey key, @Nullable T value) { putValue(key, (Value) value); return this; } - data.add(key); - data.add(value); + addPair(key, value); return this; } + /** + * Store the given key/value pair. In unlimited mode: append. In limited mode: dedup by name + * (last-value-wins), truncate over-length values, and drop entries beyond capacity. + */ + private void addPair(AttributeKey key, Object value) { + if (!isLimited()) { + data.add(key); + data.add(value); + size++; + return; + } + cachedBuild = null; + Object limited = + lengthLimit == Integer.MAX_VALUE + ? value + : AttributeLengthLimits.applyAttributeLengthLimit(value, lengthLimit); + String name = key.getKey(); + int emptySlot = -1; + for (int i = 0; i < data.size(); i += 2) { + Object existing = data.get(i); + if (existing == null) { + if (emptySlot < 0) { + emptySlot = i; + } + continue; + } + if (((AttributeKey) existing).getKey().equals(name)) { + data.set(i, key); + data.set(i + 1, limited); + return; + } + } + if (size >= capacity) { + return; + } + if (emptySlot >= 0) { + data.set(emptySlot, key); + data.set(emptySlot + 1, limited); + } else { + data.add(key); + data.add(limited); + } + size++; + } + @SuppressWarnings("unchecked") private void putValue(AttributeKey key, Value valueObj) { // Convert VALUE type to narrower type when possible @@ -111,8 +202,7 @@ private void putValue(AttributeKey key, Value valueObj) { return; case VALUE: // Not coercible (empty, non-homogeneous, or unsupported element type) - data.add(key); - data.add(valueObj); + addPair(key, valueObj); return; default: throw new IllegalArgumentException("Unexpected array attribute type: " + attributeType); @@ -121,8 +211,7 @@ private void putValue(AttributeKey key, Value valueObj) { case BYTES: case EMPTY: // Keep as VALUE type - data.add(key); - data.add(valueObj); + addPair(key, valueObj); } } @@ -191,6 +280,8 @@ public AttributesBuilder removeIf(Predicate> predicate) { // null items are filtered out in ArrayBackedAttributes data.set(i, null); data.set(i + 1, null); + size--; + cachedBuild = null; } } return this; diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java new file mode 100644 index 00000000000..a7dd31fdca0 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java @@ -0,0 +1,74 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.common; + +import com.google.auto.value.AutoValue; +import javax.annotation.concurrent.Immutable; + +/** + * Limits enforced by an {@link AttributesBuilder} created via {@link + * Attributes#builder(AttributeLimits)}. + * + *

A builder configured with limits applies last-value-wins semantics on {@link + * AttributesBuilder#put put} (by {@link AttributeKey#getKey() key name}, regardless of {@link + * AttributeType}), truncates over-length string values, and drops entries added beyond the + * configured capacity. This differs from the default builder ({@link Attributes#builder()}) which + * defers de-duplication to {@link AttributesBuilder#build()} and applies no truncation or capacity. + * + *

Use {@link #noLimits()} to represent an unbounded configuration and {@link #builder()} to + * construct a bounded one. + * + * @since 1.64.0 + */ +public abstract class AttributeLimits { + + private static final AttributeLimits NO_LIMITS = new AttributeLimitsBuilder().build(); + + /** Returns an {@link AttributeLimits} that imposes no capacity or length limits. */ + public static AttributeLimits noLimits() { + return NO_LIMITS; + } + + /** Returns a new {@link AttributeLimitsBuilder} initialized to {@link #noLimits()}. */ + public static AttributeLimitsBuilder builder() { + return new AttributeLimitsBuilder(); + } + + static AttributeLimits create(int capacity, int lengthLimit) { + return new AutoValue_AttributeLimits_AttributeLimitsValue(capacity, lengthLimit); + } + + /** Package-private constructor to prevent subclassing. */ + AttributeLimits() {} + + /** + * Returns the maximum number of unique attribute keys. Additional entries with new key names are + * dropped. Overwrites of existing keys do not consume capacity. + * + *

{@link Integer#MAX_VALUE} means no capacity limit. + */ + public abstract int getCapacity(); + + /** + * Returns the maximum length for string and string-array attribute values. Longer values are + * truncated to this length. Applies recursively to nested {@link Value}-typed attributes. + * + *

{@link Integer#MAX_VALUE} means no length limit. + */ + public abstract int getLengthLimit(); + + /** + * Returns an {@link AttributeLimitsBuilder} initialized to the same property values as this + * instance. + */ + public AttributeLimitsBuilder toBuilder() { + return builder().setCapacity(getCapacity()).setLengthLimit(getLengthLimit()); + } + + @AutoValue + @Immutable + abstract static class AttributeLimitsValue extends AttributeLimits {} +} diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java new file mode 100644 index 00000000000..446f1fdd159 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.common; + +import static io.opentelemetry.api.internal.Utils.checkArgument; + +/** + * Builder for {@link AttributeLimits}. + * + * @since 1.64.0 + */ +public final class AttributeLimitsBuilder { + + private int capacity = Integer.MAX_VALUE; + private int lengthLimit = Integer.MAX_VALUE; + + AttributeLimitsBuilder() {} + + /** + * Sets the maximum number of unique attribute keys. Additional entries with new key names are + * dropped once the capacity is reached. Overwrites of existing keys do not consume capacity. + * + * @param capacity the maximum number of unique attribute keys; must be non-negative. Use {@link + * Integer#MAX_VALUE} for no capacity limit. + */ + public AttributeLimitsBuilder setCapacity(int capacity) { + checkArgument(capacity >= 0, "capacity must be non-negative"); + this.capacity = capacity; + return this; + } + + /** + * Sets the maximum length for string and string-array attribute values. Longer values are + * truncated. Applies recursively to nested {@link Value}-typed attributes. + * + * @param lengthLimit the maximum length; must be non-negative. Use {@link Integer#MAX_VALUE} for + * no length limit. + */ + public AttributeLimitsBuilder setLengthLimit(int lengthLimit) { + checkArgument(lengthLimit >= 0, "lengthLimit must be non-negative"); + this.lengthLimit = lengthLimit; + return this; + } + + /** Builds the {@link AttributeLimits}. */ + public AttributeLimits build() { + return AttributeLimits.create(capacity, lengthLimit); + } +} diff --git a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java index de1e624f361..71a936cbf24 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java @@ -221,6 +221,21 @@ static AttributesBuilder builder() { return new ArrayBackedAttributesBuilder(); } + /** + * Returns a new {@link AttributesBuilder} instance that enforces the given {@link + * AttributeLimits}. The returned builder applies last-value-wins semantics on {@link + * AttributesBuilder#put put} (by {@link AttributeKey#getKey() key name}, regardless of {@link + * AttributeType}), truncates over-length string values, and drops entries added beyond the + * configured capacity. + * + *

Passing {@link AttributeLimits#noLimits()} is functionally equivalent to {@link #builder()}. + * + * @since 1.64.0 + */ + static AttributesBuilder builder(AttributeLimits limits) { + return new ArrayBackedAttributesBuilder(limits); + } + /** * Returns a new {@link AttributesBuilder} instance populated with the data of this {@link * Attributes}. diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java new file mode 100644 index 00000000000..343d8f130a5 --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java @@ -0,0 +1,154 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.internal; + +import io.opentelemetry.api.common.KeyValue; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.common.ValueType; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * Truncation helpers for attribute values. Strings and byte-array values that exceed a configured + * length limit are truncated; array and key-value-list values are recursively processed. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class AttributeLengthLimits { + + private AttributeLengthLimits() {} + + /** + * Apply the {@code lengthLimit} to the attribute {@code value}. Strings, byte arrays, and nested + * values which exceed the length limit are truncated. + * + *

Applies to: + * + *

    + *
  • String values + *
  • Each string within an array of strings + *
  • String values within {@link Value} objects + *
  • Byte array values within {@link Value} objects + *
  • Recursively, each element in an array of {@link Value}s + *
  • Recursively, each value in a {@link Value} key-value list + *
+ */ + public static Object applyAttributeLengthLimit(Object value, int lengthLimit) { + if (lengthLimit == Integer.MAX_VALUE) { + return value; + } + if (value instanceof List) { + List values = (List) value; + List response = new ArrayList<>(values.size()); + for (Object entry : values) { + response.add(applyAttributeLengthLimit(entry, lengthLimit)); + } + return response; + } + if (value instanceof String) { + String str = (String) value; + return str.length() < lengthLimit ? value : str.substring(0, lengthLimit); + } + if (value instanceof Value) { + return applyValueLengthLimit((Value) value, lengthLimit); + } + return value; + } + + /** + * Returns true if {@code value} contains no strings, byte arrays, or nested values exceeding + * {@code lengthLimit}. + */ + public static boolean isValidLength(Object value, int lengthLimit) { + if (value instanceof List) { + return allMatch((List) value, entry -> isValidLength(entry, lengthLimit)); + } else if (value instanceof String) { + return ((String) value).length() < lengthLimit; + } else if (value instanceof Value) { + return isValidLengthValue((Value) value, lengthLimit); + } + return true; + } + + private static boolean isValidLengthValue(Value value, int lengthLimit) { + ValueType type = value.getType(); + if (type == ValueType.STRING) { + return ((String) value.getValue()).length() < lengthLimit; + } else if (type == ValueType.BYTES) { + ByteBuffer buffer = (ByteBuffer) value.getValue(); + return buffer.remaining() <= lengthLimit; + } else if (type == ValueType.ARRAY) { + @SuppressWarnings("unchecked") + List> array = (List>) value.getValue(); + return allMatch(array, element -> isValidLengthValue(element, lengthLimit)); + } else if (type == ValueType.KEY_VALUE_LIST) { + @SuppressWarnings("unchecked") + List kvList = (List) value.getValue(); + return allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); + } + return true; + } + + private static boolean allMatch(Iterable iterable, Predicate predicate) { + for (T value : iterable) { + if (!predicate.test(value)) { + return false; + } + } + return true; + } + + @SuppressWarnings("unchecked") + private static Value applyValueLengthLimit(Value value, int lengthLimit) { + ValueType type = value.getType(); + + if (type == ValueType.STRING) { + String str = (String) value.getValue(); + if (str.length() <= lengthLimit) { + return value; + } + return Value.of(str.substring(0, lengthLimit)); + } else if (type == ValueType.BYTES) { + ByteBuffer buffer = (ByteBuffer) value.getValue(); + int length = buffer.remaining(); + if (length <= lengthLimit) { + return value; + } + byte[] truncated = new byte[lengthLimit]; + buffer.get(truncated); + return Value.of(truncated); + } else if (type == ValueType.ARRAY) { + List> array = (List>) value.getValue(); + boolean allValidLength = allMatch(array, element -> isValidLengthValue(element, lengthLimit)); + if (allValidLength) { + return value; + } + List> result = new ArrayList<>(array.size()); + for (Value element : array) { + result.add(applyValueLengthLimit(element, lengthLimit)); + } + return Value.of(result); + } else if (type == ValueType.KEY_VALUE_LIST) { + List kvList = (List) value.getValue(); + boolean allValidLength = + allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); + if (allValidLength) { + return value; + } + List result = new ArrayList<>(kvList.size()); + for (KeyValue kv : kvList) { + result.add(KeyValue.of(kv.getKey(), applyValueLengthLimit(kv.getValue(), lengthLimit))); + } + return Value.of(result.toArray(new KeyValue[0])); + } + + // For BOOLEAN, LONG, DOUBLE - no truncation needed + return value; + } +} diff --git a/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java b/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java new file mode 100644 index 00000000000..e457f870b9a --- /dev/null +++ b/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java @@ -0,0 +1,67 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +class AttributeLimitsTest { + + @Test + void noLimits_returnsUnbounded() { + AttributeLimits limits = AttributeLimits.noLimits(); + assertThat(limits.getCapacity()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getLengthLimit()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void noLimits_isCached() { + assertThat(AttributeLimits.noLimits()).isSameAs(AttributeLimits.noLimits()); + } + + @Test + void builder_defaultsToUnbounded() { + AttributeLimits limits = AttributeLimits.builder().build(); + assertThat(limits.getCapacity()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getLengthLimit()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void builder_setsFields() { + AttributeLimits limits = + AttributeLimits.builder().setCapacity(128).setLengthLimit(1024).build(); + assertThat(limits.getCapacity()).isEqualTo(128); + assertThat(limits.getLengthLimit()).isEqualTo(1024); + } + + @Test + void builder_rejectsNegative() { + assertThatIllegalArgumentException() + .isThrownBy(() -> AttributeLimits.builder().setCapacity(-1)); + assertThatIllegalArgumentException() + .isThrownBy(() -> AttributeLimits.builder().setLengthLimit(-1)); + } + + @Test + void toBuilder_preservesFields() { + AttributeLimits original = + AttributeLimits.builder().setCapacity(64).setLengthLimit(512).build(); + AttributeLimits copy = original.toBuilder().build(); + assertThat(copy.getCapacity()).isEqualTo(64); + assertThat(copy.getLengthLimit()).isEqualTo(512); + } + + @Test + void equals_valueBased() { + AttributeLimits a = AttributeLimits.builder().setCapacity(10).setLengthLimit(20).build(); + AttributeLimits b = AttributeLimits.builder().setCapacity(10).setLengthLimit(20).build(); + AttributeLimits c = AttributeLimits.builder().setCapacity(10).setLengthLimit(21).build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(c); + } +} diff --git a/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java new file mode 100644 index 00000000000..4753713b136 --- /dev/null +++ b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java @@ -0,0 +1,102 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.common; + +import static io.opentelemetry.api.common.AttributeKey.booleanKey; +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 org.junit.jupiter.api.Test; + +/** + * Exercises the limits-enforcing builder returned by {@link Attributes#builder(AttributeLimits)}. + */ +class BoundedAttributesBuilderTest { + + @Test + void noLimits_equivalentToDefaultBuilder() { + Attributes attrs = + Attributes.builder(AttributeLimits.noLimits()) + .put(stringKey("k"), "v") + .put(longKey("n"), 1L) + .build(); + assertThat(attrs.size()).isEqualTo(2); + } + + @Test + void sameNameDifferentType_lastValueWins() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + .put(stringKey("k"), "hello") + .put(booleanKey("k"), true) + .build(); + assertThat(attrs.size()).isEqualTo(1); + assertThat(attrs.get(booleanKey("k"))).isEqualTo(true); + assertThat(attrs.get(stringKey("k"))).isNull(); + } + + @Test + void sameNameDifferentType_doesNotConsumeExtraCapacity() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setCapacity(2).build()) + .put(stringKey("a"), "v1") + .put(booleanKey("a"), false) + .put(longKey("b"), 42L) + .build(); + assertThat(attrs.size()).isEqualTo(2); + assertThat(attrs.get(booleanKey("a"))).isEqualTo(false); + assertThat(attrs.get(longKey("b"))).isEqualTo(42L); + } + + @Test + void capacityDropsOverflow() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setCapacity(2).build()) + .put(stringKey("a"), "v1") + .put(stringKey("b"), "v2") + .put(stringKey("c"), "v3") + .build(); + assertThat(attrs.size()).isEqualTo(2); + assertThat(attrs.get(stringKey("c"))).isNull(); + } + + @Test + void lengthLimitTruncatesStringValues() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setLengthLimit(3).build()) + .put(stringKey("k"), "hello") + .build(); + assertThat(attrs.get(stringKey("k"))).isEqualTo("hel"); + } + + @Test + void buildIsCached_returnsSameInstanceBetweenMutations() { + AttributesBuilder builder = + Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + .put(stringKey("a"), "v"); + Attributes first = builder.build(); + Attributes second = builder.build(); + assertThat(second).isSameAs(first); + builder.put(stringKey("b"), "v2"); + Attributes third = builder.build(); + assertThat(third).isNotSameAs(first); + assertThat(third.size()).isEqualTo(2); + } + + @Test + void remove_invalidatesCache() { + AttributesBuilder builder = + Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + .put(stringKey("a"), "v1") + .put(stringKey("b"), "v2"); + Attributes first = builder.build(); + builder.remove(stringKey("a")); + Attributes second = builder.build(); + assertThat(second).isNotSameAs(first); + assertThat(second.size()).isEqualTo(1); + } +} diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt index d55eea5331d..7f84f6b462a 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt @@ -1,2 +1,18 @@ Comparing source compatibility of opentelemetry-api-1.64.0-SNAPSHOT.jar against opentelemetry-api-1.63.0.jar -No changes. \ No newline at end of file ++++ NEW CLASS: PUBLIC(+) ABSTRACT(+) io.opentelemetry.api.common.AttributeLimits (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.common.AttributeLimitsBuilder builder() + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) int getCapacity() + +++ NEW METHOD: PUBLIC(+) ABSTRACT(+) int getLengthLimit() + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.common.AttributeLimits noLimits() + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.common.AttributeLimitsBuilder toBuilder() ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.opentelemetry.api.common.AttributeLimitsBuilder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.common.AttributeLimits build() + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.common.AttributeLimitsBuilder setCapacity(int) + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.common.AttributeLimitsBuilder setLengthLimit(int) +*** MODIFIED INTERFACE: PUBLIC ABSTRACT io.opentelemetry.api.common.Attributes (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.common.AttributesBuilder builder(io.opentelemetry.api.common.AttributeLimits) diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java index 54a14c62bc5..b89746df97d 100644 --- a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java @@ -8,14 +8,8 @@ import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; -import io.opentelemetry.api.common.KeyValue; -import io.opentelemetry.api.common.Value; -import io.opentelemetry.api.common.ValueType; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; +import io.opentelemetry.api.internal.AttributeLengthLimits; import java.util.Map; -import java.util.function.Predicate; /** * This class is internal and is hence not for public use. Its APIs are unstable and can change at @@ -39,8 +33,13 @@ public static Attributes applyAttributesLimit( if (lengthLimit == Integer.MAX_VALUE) { return attributes; } - boolean allValidLength = - allMatch(attributes.asMap().values(), value -> isValidLength(value, lengthLimit)); + boolean allValidLength = true; + for (Object value : attributes.asMap().values()) { + if (!AttributeLengthLimits.isValidLength(value, lengthLimit)) { + allValidLength = false; + break; + } + } if (allValidLength) { return attributes; } @@ -53,133 +52,10 @@ public static Attributes applyAttributesLimit( break; } result.put( - (AttributeKey) entry.getKey(), applyAttributeLengthLimit(entry.getValue(), lengthLimit)); + (AttributeKey) entry.getKey(), + AttributeLengthLimits.applyAttributeLengthLimit(entry.getValue(), lengthLimit)); i++; } return result.build(); } - - private static boolean isValidLength(Object value, int lengthLimit) { - if (value instanceof List) { - return allMatch((List) value, entry -> isValidLength(entry, lengthLimit)); - } else if (value instanceof String) { - return ((String) value).length() < lengthLimit; - } else if (value instanceof Value) { - return isValidLengthValue((Value) value, lengthLimit); - } - return true; - } - - private static boolean isValidLengthValue(Value value, int lengthLimit) { - ValueType type = value.getType(); - if (type == ValueType.STRING) { - return ((String) value.getValue()).length() < lengthLimit; - } else if (type == ValueType.BYTES) { - ByteBuffer buffer = (ByteBuffer) value.getValue(); - return buffer.remaining() <= lengthLimit; - } else if (type == ValueType.ARRAY) { - @SuppressWarnings("unchecked") - List> array = (List>) value.getValue(); - return allMatch(array, element -> isValidLengthValue(element, lengthLimit)); - } else if (type == ValueType.KEY_VALUE_LIST) { - @SuppressWarnings("unchecked") - List kvList = (List) value.getValue(); - return allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); - } - return true; - } - - private static boolean allMatch(Iterable iterable, Predicate predicate) { - for (T value : iterable) { - if (!predicate.test(value)) { - return false; - } - } - return true; - } - - /** - * Apply the {@code lengthLimit} to the attribute {@code value}. Strings, byte arrays, and nested - * values which exceed the length limit are truncated. - * - *

Applies to: - * - *

    - *
  • String values - *
  • Each string within an array of strings - *
  • String values within {@link Value} objects - *
  • Byte array values within {@link Value} objects - *
  • Recursively, each element in an array of {@link Value}s - *
  • Recursively, each value in a {@link Value} key-value list - *
- */ - public static Object applyAttributeLengthLimit(Object value, int lengthLimit) { - if (lengthLimit == Integer.MAX_VALUE) { - return value; - } - if (value instanceof List) { - List values = (List) value; - List response = new ArrayList<>(values.size()); - for (Object entry : values) { - response.add(applyAttributeLengthLimit(entry, lengthLimit)); - } - return response; - } - if (value instanceof String) { - String str = (String) value; - return str.length() < lengthLimit ? value : str.substring(0, lengthLimit); - } - if (value instanceof Value) { - return applyValueLengthLimit((Value) value, lengthLimit); - } - return value; - } - - @SuppressWarnings("unchecked") - private static Value applyValueLengthLimit(Value value, int lengthLimit) { - ValueType type = value.getType(); - - if (type == ValueType.STRING) { - String str = (String) value.getValue(); - if (str.length() <= lengthLimit) { - return value; - } - return Value.of(str.substring(0, lengthLimit)); - } else if (type == ValueType.BYTES) { - ByteBuffer buffer = (ByteBuffer) value.getValue(); - int length = buffer.remaining(); - if (length <= lengthLimit) { - return value; - } - byte[] truncated = new byte[lengthLimit]; - buffer.get(truncated); - return Value.of(truncated); - } else if (type == ValueType.ARRAY) { - List> array = (List>) value.getValue(); - boolean allValidLength = allMatch(array, element -> isValidLengthValue(element, lengthLimit)); - if (allValidLength) { - return value; - } - List> result = new ArrayList<>(array.size()); - for (Value element : array) { - result.add(applyValueLengthLimit(element, lengthLimit)); - } - return Value.of(result); - } else if (type == ValueType.KEY_VALUE_LIST) { - List kvList = (List) value.getValue(); - boolean allValidLength = - allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); - if (allValidLength) { - return value; - } - List result = new ArrayList<>(kvList.size()); - for (KeyValue kv : kvList) { - result.add(KeyValue.of(kv.getKey(), applyValueLengthLimit(kv.getValue(), lengthLimit))); - } - return Value.of(result.toArray(new KeyValue[0])); - } - - // For BOOLEAN, LONG, DOUBLE - no truncation needed - return value; - } } diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java deleted file mode 100644 index 0ddf9599752..00000000000 --- a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.sdk.common.internal; - -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.Map; -import java.util.function.BiConsumer; -import javax.annotation.Nullable; - -/** - * A map with a fixed capacity that drops attributes when the map gets full, and which truncates - * string and array string attribute values to the {@link #lengthLimit}. - * - *

WARNING: In order to reduce memory allocation, this class extends {@link HashMap} when it - * would be more appropriate to delegate. The problem with extending is that we don't enforce that - * all {@link HashMap} methods for reading / writing data conform to the configured attribute - * limits. Therefore, it's easy to accidentally call something like {@link Map#putAll(Map)} and - * bypass the restrictions (see #7135). Callers MUST - * take care to only call methods from {@link AttributesMap}, and not {@link HashMap}. - * - *

This class is internal and is hence not for public use. Its APIs are unstable and can change - * at any time. - */ -public final class AttributesMap extends HashMap, Object> implements Attributes { - - private static final long serialVersionUID = -5072696312123632376L; - - private final long capacity; - private final int lengthLimit; - private int totalAddedValues = 0; - - private AttributesMap(long capacity, int lengthLimit) { - this.capacity = capacity; - this.lengthLimit = lengthLimit; - } - - /** - * Create an instance. - * - * @param capacity the max number of attribute entries - * @param lengthLimit the maximum length of string attributes - */ - public static AttributesMap create(long capacity, int lengthLimit) { - return new AttributesMap(capacity, lengthLimit); - } - - /** - * Add the attribute key value pair, applying capacity and length limits. Callers MUST ensure the - * {@code value} type matches the type required by {@code key}. - */ - @Override - @Nullable - public Object put(AttributeKey key, @Nullable Object value) { - if (value == null) { - return null; - } - totalAddedValues++; - if (size() >= capacity && !containsKey(key)) { - return null; - } - return super.put(key, AttributeUtil.applyAttributeLengthLimit(value, lengthLimit)); - } - - /** Generic overload of {@link #put(AttributeKey, Object)}. */ - public void putIfCapacity(AttributeKey key, @Nullable T value) { - put(key, value); - } - - /** Get the total number of attributes added, including those dropped for capacity limits. */ - public int getTotalAddedValues() { - return totalAddedValues; - } - - @SuppressWarnings("unchecked") - @Override - @Nullable - public T get(AttributeKey key) { - return (T) super.get(key); - } - - @Override - public Map, Object> asMap() { - // Because Attributes is marked Immutable, IDEs may recognize this as redundant usage. However, - // this class is private and is actually mutable, so we need to wrap with unmodifiableMap - // anyways. We implement the immutable Attributes for this class to support the - // Attributes.builder().putAll usage - it is tricky but an implementation detail of this private - // class. - return Collections.unmodifiableMap(this); - } - - @Override - public AttributesBuilder toBuilder() { - return Attributes.builder().putAll(this); - } - - @Override - public void forEach(BiConsumer, ? super Object> action) { - // https://github.com/open-telemetry/opentelemetry-java/issues/4161 - // Help out android desugaring by having an explicit call to HashMap.forEach, when forEach is - // just called through Attributes.forEach desugaring is unable to correctly handle it. - super.forEach(action); - } - - @Override - public String toString() { - return "AttributesMap{" - + "data=" - + super.toString() - + ", capacity=" - + capacity - + ", totalAddedValues=" - + totalAddedValues - + '}'; - } - - /** Create an immutable copy of the attributes in this map. */ - public Attributes immutableCopy() { - return Attributes.builder().putAll(this).build(); - } -} diff --git a/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java b/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java deleted file mode 100644 index a7a1f8ecb2e..00000000000 --- a/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.sdk.common.internal; - -import static io.opentelemetry.api.common.AttributeKey.longKey; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.entry; - -import org.junit.jupiter.api.Test; - -class AttributesMapTest { - - @Test - void asMap() { - AttributesMap attributesMap = AttributesMap.create(2, Integer.MAX_VALUE); - attributesMap.put(longKey("one"), 1L); - attributesMap.put(longKey("two"), 2L); - - assertThat(attributesMap.asMap()) - .containsOnly(entry(longKey("one"), 1L), entry(longKey("two"), 2L)); - } -} diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java index 7f51ad9f4b2..665cad81e2f 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java @@ -6,13 +6,15 @@ package io.opentelemetry.sdk.logs; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; import io.opentelemetry.api.logs.LogRecordBuilder; import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; -import io.opentelemetry.sdk.common.internal.AttributesMap; import java.time.Instant; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; @@ -32,7 +34,8 @@ class SdkLogRecordBuilder implements LogRecordBuilder { @Nullable private String severityText; @Nullable private Value body; @Nullable private String eventName; - @Nullable private AttributesMap attributes; + @Nullable private AttributesBuilder attributes; + private int totalAttributeCount = 0; SdkLogRecordBuilder( LoggerSharedState loggerSharedState, @@ -128,9 +131,13 @@ public SdkLogRecordBuilder setAttribute(AttributeKey key, @Nullable T val } if (this.attributes == null) { this.attributes = - AttributesMap.create( - logLimits.getMaxNumberOfAttributes(), logLimits.getMaxAttributeValueLength()); + Attributes.builder( + AttributeLimits.builder() + .setCapacity(logLimits.getMaxNumberOfAttributes()) + .setLengthLimit(logLimits.getMaxAttributeValueLength()) + .build()); } + totalAttributeCount++; this.attributes.put(key, value); return this; } @@ -163,7 +170,7 @@ protected void setExceptionAttribute(AttributeKey key, @Nullable T value) if (key == null || key.getKey().isEmpty() || value == null) { return; } - if (attributes == null || attributes.get(key) == null) { + if (attributes == null || attributes.build().get(key) == null) { setAttribute(key, value); } } @@ -180,6 +187,7 @@ protected ReadWriteLogRecord createLogRecord(Context context, long observedTimes severityText, body, attributes, + totalAttributeCount, eventName); } } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java index 4bb837668fd..ef27901e670 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java @@ -6,13 +6,14 @@ package io.opentelemetry.sdk.logs; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; import io.opentelemetry.api.internal.GuardedBy; import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; -import io.opentelemetry.sdk.common.internal.AttributesMap; import io.opentelemetry.sdk.logs.data.LogRecordData; import io.opentelemetry.sdk.resources.Resource; import javax.annotation.Nullable; @@ -35,7 +36,10 @@ class SdkReadWriteLogRecord implements ReadWriteLogRecord { @GuardedBy("lock") @Nullable - private AttributesMap attributes; + private AttributesBuilder attributes; + + @GuardedBy("lock") + private int totalAttributeCount; protected SdkReadWriteLogRecord( LogLimits logLimits, @@ -47,7 +51,8 @@ protected SdkReadWriteLogRecord( Severity severity, @Nullable String severityText, @Nullable Value body, - @Nullable AttributesMap attributes, + @Nullable AttributesBuilder attributes, + int initialTotalAttributeCount, @Nullable String eventName) { this.logLimits = logLimits; this.resource = resource; @@ -60,6 +65,7 @@ protected SdkReadWriteLogRecord( this.body = body; this.eventName = eventName; this.attributes = attributes; + this.totalAttributeCount = initialTotalAttributeCount; } /** Create the log record with the given configuration. */ @@ -73,7 +79,8 @@ static SdkReadWriteLogRecord create( Severity severity, @Nullable String severityText, @Nullable Value body, - @Nullable AttributesMap attributes, + @Nullable AttributesBuilder attributes, + int initialTotalAttributeCount, @Nullable String eventName) { return new SdkReadWriteLogRecord( logLimits, @@ -86,6 +93,7 @@ static SdkReadWriteLogRecord create( severityText, body, attributes, + initialTotalAttributeCount, eventName); } @@ -97,9 +105,13 @@ public ReadWriteLogRecord setAttribute(AttributeKey key, T value) { synchronized (lock) { if (attributes == null) { attributes = - AttributesMap.create( - logLimits.getMaxNumberOfAttributes(), logLimits.getMaxAttributeValueLength()); + Attributes.builder( + AttributeLimits.builder() + .setCapacity(logLimits.getMaxNumberOfAttributes()) + .setLengthLimit(logLimits.getMaxAttributeValueLength()) + .build()); } + totalAttributeCount++; attributes.put(key, value); } return this; @@ -107,10 +119,7 @@ public ReadWriteLogRecord setAttribute(AttributeKey key, T value) { protected Attributes getImmutableAttributes() { synchronized (lock) { - if (attributes == null || attributes.isEmpty()) { - return Attributes.empty(); - } - return attributes.immutableCopy(); + return attributes == null ? Attributes.empty() : attributes.build(); } } @@ -127,7 +136,7 @@ public LogRecordData toLogRecordData() { severityText, body, getImmutableAttributes(), - attributes == null ? 0 : attributes.getTotalAddedValues(), + totalAttributeCount, eventName); } } @@ -184,10 +193,7 @@ public String getEventName() { @Override public T getAttribute(AttributeKey key) { synchronized (lock) { - if (attributes == null || attributes.isEmpty()) { - return null; - } - return attributes.get(key); + return attributes == null ? null : attributes.build().get(key); } } } diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java index 1edc99f97f4..3d720af49f3 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java @@ -8,12 +8,13 @@ import static io.opentelemetry.api.common.AttributeKey.stringKey; import static org.assertj.core.api.Assertions.assertThat; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; -import io.opentelemetry.sdk.common.internal.AttributesMap; import io.opentelemetry.sdk.resources.Resource; import org.junit.jupiter.api.Test; @@ -50,7 +51,8 @@ void allHandlesEmpty() { SdkReadWriteLogRecord buildLogRecord() { Value body = Value.of("bod"); - AttributesMap initialAttributes = AttributesMap.create(100, 200); + AttributesBuilder initialAttributes = + Attributes.builder(AttributeLimits.builder().setCapacity(100).setLengthLimit(200).build()); initialAttributes.put(stringKey("foo"), "aaiosjfjioasdiojfjioasojifja"); initialAttributes.put(stringKey("untouched"), "yes"); LogLimits limits = LogLimits.getDefault(); @@ -69,6 +71,7 @@ SdkReadWriteLogRecord buildLogRecord() { "buggin", body, initialAttributes, + 2, "my.event.name"); } } diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java index b67ac3d403b..155275e4cee 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java @@ -6,7 +6,9 @@ package io.opentelemetry.sdk.trace; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.internal.GuardedBy; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; @@ -17,7 +19,6 @@ import io.opentelemetry.sdk.common.InstrumentationLibraryInfo; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.common.internal.AttributeUtil; -import io.opentelemetry.sdk.common.internal.AttributesMap; import io.opentelemetry.sdk.common.internal.ExceptionAttributeResolver; import io.opentelemetry.sdk.common.internal.InstrumentationScopeUtil; import io.opentelemetry.sdk.resources.Resource; @@ -74,7 +75,12 @@ final class SdkSpan implements ReadWriteSpan { // Set of recorded attributes. DO NOT CALL any other method that changes the ordering of events. @GuardedBy("lock") @Nullable - private AttributesMap attributes; + private AttributesBuilder attributes; + + // Count of setAttribute() calls with a non-null value, including those dropped for capacity. The + // builder does not track this since capacity-drop is not observable from AttributesBuilder. + @GuardedBy("lock") + private int totalAttributeCount = 0; // List of recorded events. @GuardedBy("lock") @@ -133,7 +139,8 @@ private SdkSpan( ExceptionAttributeResolver exceptionAttributeResolver, AnchoredClock clock, Resource resource, - @Nullable AttributesMap attributes, + @Nullable AttributesBuilder attributes, + int initialTotalAttributeCount, @Nullable List links, int totalRecordedLinks, long startEpochNanos, @@ -152,6 +159,7 @@ private SdkSpan( this.clock = clock; this.startEpochNanos = startEpochNanos; this.attributes = attributes; + this.totalAttributeCount = initialTotalAttributeCount; this.spanLimits = spanLimits; this.recordEndMetrics = recordEndMetrics; } @@ -184,7 +192,8 @@ static SdkSpan startSpan( ExceptionAttributeResolver exceptionAttributeResolver, Clock tracerClock, Resource resource, - @Nullable AttributesMap attributes, + @Nullable AttributesBuilder attributes, + int initialTotalAttributeCount, @Nullable List links, int totalRecordedLinks, long userStartEpochNanos, @@ -225,6 +234,7 @@ static SdkSpan startSpan( clock, resource, attributes, + initialTotalAttributeCount, links, totalRecordedLinks, startEpochNanos, @@ -246,7 +256,7 @@ public SpanData toSpanData() { getImmutableLinks(), getImmutableTimedEvents(), getImmutableAttributes(), - (attributes == null) ? 0 : attributes.getTotalAddedValues(), + totalAttributeCount, totalRecordedEvents, totalRecordedLinks, status, @@ -260,14 +270,14 @@ public SpanData toSpanData() { @Nullable public T getAttribute(AttributeKey key) { synchronized (lock) { - return attributes == null ? null : attributes.get(key); + return attributes == null ? null : attributes.build().get(key); } } @Override public Attributes getAttributes() { synchronized (lock) { - return attributes == null ? Attributes.empty() : attributes.immutableCopy(); + return attributes == null ? Attributes.empty() : attributes.build(); } } @@ -340,16 +350,22 @@ public ReadWriteSpan setAttribute(AttributeKey key, @Nullable T value) { return this; } if (attributes == null) { - attributes = - AttributesMap.create( - spanLimits.getMaxNumberOfAttributes(), spanLimits.getMaxAttributeValueLength()); + attributes = Attributes.builder(attributeLimits(spanLimits)); } + totalAttributeCount++; attributes.put(key, value); } return this; } + private static AttributeLimits attributeLimits(SpanLimits spanLimits) { + return AttributeLimits.builder() + .setCapacity(spanLimits.getMaxNumberOfAttributes()) + .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .build(); + } + @GuardedBy("lock") @SuppressWarnings("ReferenceEquality") private boolean isModifiableByCurrentThread() { @@ -483,18 +499,27 @@ public ReadWriteSpan recordException(Throwable exception, Attributes additionalA } int maxAttributeLength = spanLimits.getMaxAttributeValueLength(); - AttributesMap attributes = - AttributesMap.create( - spanLimits.getMaxNumberOfAttributes(), spanLimits.getMaxAttributeValueLength()); + AttributesBuilder eventAttributes = Attributes.builder(attributeLimits(spanLimits)); + int[] counter = {0}; exceptionAttributeResolver.setExceptionAttributes( - attributes::putIfCapacity, exception, maxAttributeLength); - - additionalAttributes.forEach(attributes::put); + new ExceptionAttributeResolver.AttributeSetter() { + @Override + public void setAttribute(AttributeKey key, @Nullable T value) { + if (value != null) { + counter[0]++; + } + eventAttributes.put(key, value); + } + }, + exception, + maxAttributeLength); + + counter[0] += additionalAttributes.size(); + eventAttributes.putAll(additionalAttributes); addTimedEvent( - ExceptionEventData.create( - clock.now(), exception, attributes, attributes.getTotalAddedValues())); + ExceptionEventData.create(clock.now(), exception, eventAttributes.build(), counter[0])); return this; } @@ -620,16 +645,12 @@ private List getImmutableTimedEvents() { @GuardedBy("lock") private Attributes getImmutableAttributes() { - if (attributes == null || attributes.isEmpty()) { + if (attributes == null) { return Attributes.empty(); } - // if the span has ended, then the attributes are unmodifiable, - // so we can return them directly and save copying all the data. - if (hasEnded == EndState.ENDED) { - return attributes; - } - // otherwise, make a copy of the data into an immutable container. - return attributes.immutableCopy(); + // The builder caches its build() result until the next mutation, so this is cheap on repeated + // calls (including when the span has ended and no further mutations can happen). + return attributes.build(); } @GuardedBy("lock") diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java index 050067db815..eeff972eefb 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java @@ -11,7 +11,9 @@ import static io.opentelemetry.api.common.AttributeKey.stringKey; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.internal.ImmutableSpanContext; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; @@ -24,7 +26,6 @@ import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.common.internal.AttributeUtil; -import io.opentelemetry.sdk.common.internal.AttributesMap; import io.opentelemetry.sdk.trace.data.LinkData; import io.opentelemetry.sdk.trace.samplers.SamplingDecision; import io.opentelemetry.sdk.trace.samplers.SamplingResult; @@ -56,7 +57,8 @@ class SdkSpanBuilder implements SpanBuilder { @Nullable private Context parent; // null means: Use current context. private SpanKind spanKind = SpanKind.INTERNAL; - @Nullable private AttributesMap attributes; + @Nullable private AttributesBuilder attributes; + private int totalAttributeCount = 0; @Nullable private List links; private int totalNumberOfLinksAdded = 0; private long startEpochNanos = 0; @@ -164,6 +166,7 @@ public SpanBuilder setAttribute(AttributeKey key, @Nullable T value) { if (key == null || key.getKey().isEmpty() || value == null) { return this; } + totalAttributeCount++; attributes().put(key, value); return this; } @@ -215,7 +218,7 @@ public Span startSpan() { // Avoid any possibility to modify the links list by adding links to the Builder after the // startSpan is called. If that happens all the links will be added in a new list. links = null; - Attributes immutableAttributes = attributes == null ? Attributes.empty() : attributes; + Attributes immutableAttributes = attributes == null ? Attributes.empty() : attributes.build(); SamplingResult samplingResult = tracerSharedState .getSampler() @@ -250,13 +253,16 @@ public Span startSpan() { } Attributes samplingAttributes = samplingResult.getAttributes(); if (!samplingAttributes.isEmpty()) { - samplingAttributes.forEach((key, value) -> attributes().put((AttributeKey) key, value)); + totalAttributeCount += samplingAttributes.size(); + attributes().putAll(samplingAttributes); } // Avoid any possibility to modify the attributes by adding attributes to the Builder after the - // startSpan is called. If that happens all the attributes will be added in a new map. - AttributesMap recordedAttributes = attributes; + // startSpan is called. If that happens all the attributes will be added in a new builder. + AttributesBuilder recordedAttributes = attributes; + int recordedTotalAttributeCount = totalAttributeCount; attributes = null; + totalAttributeCount = 0; return SdkSpan.startSpan( spanContext, @@ -271,18 +277,22 @@ public Span startSpan() { tracerSharedState.getClock(), tracerSharedState.getResource(), recordedAttributes, + recordedTotalAttributeCount, currentLinks, totalNumberOfLinksAdded, startEpochNanos, recordEndSpanMetrics); } - private AttributesMap attributes() { - AttributesMap attributes = this.attributes; + private AttributesBuilder attributes() { + AttributesBuilder attributes = this.attributes; if (attributes == null) { this.attributes = - AttributesMap.create( - spanLimits.getMaxNumberOfAttributes(), spanLimits.getMaxAttributeValueLength()); + Attributes.builder( + AttributeLimits.builder() + .setCapacity(spanLimits.getMaxNumberOfAttributes()) + .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .build()); attributes = this.attributes; } return attributes; diff --git a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanBuilderTest.java b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanBuilderTest.java index 2a69ade6702..fb52239aa1b 100644 --- a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanBuilderTest.java +++ b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanBuilderTest.java @@ -1119,7 +1119,7 @@ void spanDataToString() { + "kind=INTERNAL, " + "startEpochNanos=[0-9]+, " + "endEpochNanos=[0-9]+, " - + "attributes=AttributesMap\\{data=\\{[^}]*}, capacity=128, totalAddedValues=2}, " + + "attributes=\\{[^}]*}, " + "totalAttributeCount=2, " + "events=\\[], " + "totalRecordedEvents=0, " diff --git a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java index bd98d601d95..3cd81b32e69 100644 --- a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java +++ b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; @@ -40,7 +41,6 @@ import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; -import io.opentelemetry.sdk.common.internal.AttributesMap; import io.opentelemetry.sdk.common.internal.ExceptionAttributeResolver; import io.opentelemetry.sdk.common.internal.InstrumentationScopeUtil; import io.opentelemetry.sdk.resources.Resource; @@ -1062,6 +1062,7 @@ void addLink_FaultIn() { testClock, resource, null, + 0, null, // exercises the fault-in path 0, 0, @@ -1407,8 +1408,12 @@ void onStartOnEndNotRequired() { ExceptionAttributeResolver.getDefault(), testClock, resource, - AttributesMap.create( - spanLimits.getMaxNumberOfAttributes(), spanLimits.getMaxAttributeValueLength()), + Attributes.builder( + AttributeLimits.builder() + .setCapacity(spanLimits.getMaxNumberOfAttributes()) + .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .build()), + 0, Collections.emptyList(), 1, 0, @@ -1485,15 +1490,24 @@ private static Consumer spanConsumer(Consumer spanConsumer) { private SdkSpan createTestSpanWithAttributes(Map attributes) { SpanLimits spanLimits = SpanLimits.getDefault(); - AttributesMap attributesMap = - AttributesMap.create( - spanLimits.getMaxNumberOfAttributes(), spanLimits.getMaxAttributeValueLength()); - attributes.forEach(attributesMap::put); + AttributesBuilder builder = + Attributes.builder( + AttributeLimits.builder() + .setCapacity(spanLimits.getMaxNumberOfAttributes()) + .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .build()); + @SuppressWarnings("unchecked") + Map, Object> raw = (Map, Object>) (Map) attributes; + for (Map.Entry, Object> entry : raw.entrySet()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + AttributeKey rawKey = entry.getKey(); + builder.put(rawKey, entry.getValue()); + } return createTestSpan( SpanKind.INTERNAL, SpanLimits.getDefault(), null, - attributesMap, + builder, singletonList(link), ExceptionAttributeResolver.getDefault()); } @@ -1532,7 +1546,7 @@ private SdkSpan createTestSpan( SpanKind kind, SpanLimits config, @Nullable String parentSpanId, - @Nullable AttributesMap attributes, + @Nullable AttributesBuilder attributes, @Nullable List links, ExceptionAttributeResolver exceptionAttributeResolver) { List linksCopy = links == null ? new ArrayList<>() : new ArrayList<>(links); @@ -1555,6 +1569,7 @@ private SdkSpan createTestSpan( testClock, resource, attributes, + attributes == null ? 0 : attributes.build().size(), linksCopy, linksCopy.size(), 0, @@ -1618,8 +1633,10 @@ void testAsSpanData() { TestClock clock = TestClock.create(); Resource resource = this.resource; Attributes attributes = TestUtils.generateRandomAttributes(); - AttributesMap attributesWithCapacity = AttributesMap.create(32, Integer.MAX_VALUE); - attributes.forEach(attributesWithCapacity::put); + AttributesBuilder attributesWithCapacity = + Attributes.builder(AttributeLimits.builder().setCapacity(32).build()); + attributesWithCapacity.putAll(attributes); + Attributes builtAttributes = attributesWithCapacity.build(); Attributes event1Attributes = TestUtils.generateRandomAttributes(); Attributes event2Attributes = TestUtils.generateRandomAttributes(); SpanContext context = @@ -1644,6 +1661,7 @@ void testAsSpanData() { clock, resource, attributesWithCapacity, + attributes.size(), singletonList(link1), 1, 0, @@ -1670,7 +1688,7 @@ void testAsSpanData() { SpanData result = readableSpan.toSpanData(); verifySpanData( result, - attributesWithCapacity, + builtAttributes, events, singletonList(link1), name, From 1967cc5880f7ba770b7b653e03ebf6ebb9505405 Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:59:15 -0500 Subject: [PATCH 2/6] Rename attribute limits to better match spec, add depth limit --- .../common/ArrayBackedAttributesBuilder.java | 47 ++-- .../api/common/AttributeLimits.java | 51 ++-- .../api/common/AttributeLimitsBuilder.java | 53 ++-- .../api/internal/AttributeLengthLimits.java | 154 ------------ .../api/internal/AttributeValueLimits.java | 232 ++++++++++++++++++ .../api/common/AttributeLimitsTest.java | 94 +++++-- .../common/BoundedAttributesBuilderTest.java | 151 +++++++++++- .../sdk/common/internal/AttributeUtil.java | 6 +- .../sdk/logs/SdkLogRecordBuilder.java | 4 +- .../sdk/logs/SdkReadWriteLogRecord.java | 4 +- .../sdk/logs/ReadWriteLogRecordTest.java | 3 +- .../io/opentelemetry/sdk/trace/SdkSpan.java | 4 +- .../sdk/trace/SdkSpanBuilder.java | 4 +- .../opentelemetry/sdk/trace/SdkSpanTest.java | 10 +- 14 files changed, 575 insertions(+), 242 deletions(-) delete mode 100644 api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java create mode 100644 api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java diff --git a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java index 7f21751d2f7..9deeb50f0ae 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java @@ -14,7 +14,7 @@ import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; import static io.opentelemetry.api.common.AttributeKey.stringKey; -import io.opentelemetry.api.internal.AttributeLengthLimits; +import io.opentelemetry.api.internal.AttributeValueLimits; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -25,13 +25,19 @@ class ArrayBackedAttributesBuilder implements AttributesBuilder { private final List data; /** Max number of unique entries. {@link Integer#MAX_VALUE} means unlimited. */ - private final int capacity; + private final int countLimit; /** - * Max length of string / string-array values. {@link Integer#MAX_VALUE} means unlimited. Only + * Max length of string / byte-array values. {@link Integer#MAX_VALUE} means unlimited. Only * consulted on the limited put path. */ - private final int lengthLimit; + private final int valueLengthLimit; + + /** + * Max nesting depth for array / map values. {@link Integer#MAX_VALUE} means unlimited. Only + * consulted on the limited put path. + */ + private final int valueDepthLimit; /** Count of non-null entries currently stored (excludes null holes from remove). */ private int size; @@ -45,27 +51,39 @@ class ArrayBackedAttributesBuilder implements AttributesBuilder { @Nullable private Attributes cachedBuild; ArrayBackedAttributesBuilder() { - this(new ArrayList<>(), Integer.MAX_VALUE, Integer.MAX_VALUE, 0); + this(new ArrayList<>(), Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, 0); } ArrayBackedAttributesBuilder(List data) { - this(data, Integer.MAX_VALUE, Integer.MAX_VALUE, data.size() / 2); + this(data, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, data.size() / 2); } ArrayBackedAttributesBuilder(AttributeLimits limits) { - this(new ArrayList<>(), limits.getCapacity(), limits.getLengthLimit(), 0); + this( + new ArrayList<>(), + limits.getCountLimit(), + limits.getValueLengthLimit(), + limits.getValueDepthLimit(), + 0); } private ArrayBackedAttributesBuilder( - List data, int capacity, int lengthLimit, int initialSize) { + List data, + int countLimit, + int valueLengthLimit, + int valueDepthLimit, + int initialSize) { this.data = data; - this.capacity = capacity; - this.lengthLimit = lengthLimit; + this.countLimit = countLimit; + this.valueLengthLimit = valueLengthLimit; + this.valueDepthLimit = valueDepthLimit; this.size = initialSize; } private boolean isLimited() { - return capacity != Integer.MAX_VALUE || lengthLimit != Integer.MAX_VALUE; + return countLimit != Integer.MAX_VALUE + || valueLengthLimit != Integer.MAX_VALUE + || valueDepthLimit != Integer.MAX_VALUE; } @Override @@ -118,10 +136,7 @@ private void addPair(AttributeKey key, Object value) { return; } cachedBuild = null; - Object limited = - lengthLimit == Integer.MAX_VALUE - ? value - : AttributeLengthLimits.applyAttributeLengthLimit(value, lengthLimit); + Object limited = AttributeValueLimits.apply(value, valueLengthLimit, valueDepthLimit); String name = key.getKey(); int emptySlot = -1; for (int i = 0; i < data.size(); i += 2) { @@ -138,7 +153,7 @@ private void addPair(AttributeKey key, Object value) { return; } } - if (size >= capacity) { + if (size >= countLimit) { return; } if (emptySlot >= 0) { diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java index a7dd31fdca0..d18d0127140 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java @@ -14,9 +14,16 @@ * *

A builder configured with limits applies last-value-wins semantics on {@link * AttributesBuilder#put put} (by {@link AttributeKey#getKey() key name}, regardless of {@link - * AttributeType}), truncates over-length string values, and drops entries added beyond the - * configured capacity. This differs from the default builder ({@link Attributes#builder()}) which - * defers de-duplication to {@link AttributesBuilder#build()} and applies no truncation or capacity. + * AttributeType}), truncates over-length string and byte values, replaces over-nested array and map + * values with empty containers, and drops entries added beyond the configured count limit. This + * differs from the default builder ({@link Attributes#builder()}) which defers de-duplication to + * {@link AttributesBuilder#build()} and applies no truncation, depth, or count limits. + * + *

The three parameters correspond to the {@code AttributeCountLimit}, {@code + * AttributeValueLengthLimit}, and {@code AttributeValueDepthLimit} configurable parameters in the + * OpenTelemetry common + * attribute-limits specification. * *

Use {@link #noLimits()} to represent an unbounded configuration and {@link #builder()} to * construct a bounded one. @@ -27,7 +34,7 @@ public abstract class AttributeLimits { private static final AttributeLimits NO_LIMITS = new AttributeLimitsBuilder().build(); - /** Returns an {@link AttributeLimits} that imposes no capacity or length limits. */ + /** Returns an {@link AttributeLimits} that imposes no count, length, or depth limits. */ public static AttributeLimits noLimits() { return NO_LIMITS; } @@ -37,35 +44,51 @@ public static AttributeLimitsBuilder builder() { return new AttributeLimitsBuilder(); } - static AttributeLimits create(int capacity, int lengthLimit) { - return new AutoValue_AttributeLimits_AttributeLimitsValue(capacity, lengthLimit); + static AttributeLimits create(int countLimit, int valueLengthLimit, int valueDepthLimit) { + return new AutoValue_AttributeLimits_AttributeLimitsValue( + countLimit, valueLengthLimit, valueDepthLimit); } /** Package-private constructor to prevent subclassing. */ AttributeLimits() {} /** - * Returns the maximum number of unique attribute keys. Additional entries with new key names are - * dropped. Overwrites of existing keys do not consume capacity. + * Returns the maximum number of unique attribute keys ({@code AttributeCountLimit}). Additional + * entries with new key names are dropped once the limit is reached. Overwrites of existing keys + * do not consume against the limit. * - *

{@link Integer#MAX_VALUE} means no capacity limit. + *

{@link Integer#MAX_VALUE} means no count limit. */ - public abstract int getCapacity(); + public abstract int getCountLimit(); /** - * Returns the maximum length for string and string-array attribute values. Longer values are - * truncated to this length. Applies recursively to nested {@link Value}-typed attributes. + * Returns the maximum length for string and byte-array attribute values ({@code + * AttributeValueLengthLimit}). Longer values are truncated to this length. Applies recursively to + * string and byte-array values within {@link Value}-typed and array attributes. * *

{@link Integer#MAX_VALUE} means no length limit. */ - public abstract int getLengthLimit(); + public abstract int getValueLengthLimit(); + + /** + * Returns the maximum nesting depth for array and map attribute values ({@code + * AttributeValueDepthLimit}). Depth counting starts at 1 for the top-level attribute value and + * increments when descending into array elements or map values. Arrays and maps at a depth + * greater than this limit are replaced with an empty container of the same shape. + * + *

{@link Integer#MAX_VALUE} means no depth limit. + */ + public abstract int getValueDepthLimit(); /** * Returns an {@link AttributeLimitsBuilder} initialized to the same property values as this * instance. */ public AttributeLimitsBuilder toBuilder() { - return builder().setCapacity(getCapacity()).setLengthLimit(getLengthLimit()); + return builder() + .setCountLimit(getCountLimit()) + .setValueLengthLimit(getValueLengthLimit()) + .setValueDepthLimit(getValueDepthLimit()); } @AutoValue diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java index 446f1fdd159..8ac07b95c7c 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java @@ -14,39 +14,58 @@ */ public final class AttributeLimitsBuilder { - private int capacity = Integer.MAX_VALUE; - private int lengthLimit = Integer.MAX_VALUE; + private int countLimit = Integer.MAX_VALUE; + private int valueLengthLimit = Integer.MAX_VALUE; + private int valueDepthLimit = Integer.MAX_VALUE; AttributeLimitsBuilder() {} /** - * Sets the maximum number of unique attribute keys. Additional entries with new key names are - * dropped once the capacity is reached. Overwrites of existing keys do not consume capacity. + * Sets the maximum number of unique attribute keys ({@code AttributeCountLimit}). Additional + * entries with new key names are dropped once the limit is reached. Overwrites of existing keys + * do not consume against the limit. * - * @param capacity the maximum number of unique attribute keys; must be non-negative. Use {@link - * Integer#MAX_VALUE} for no capacity limit. + * @param countLimit non-negative maximum, or {@link Integer#MAX_VALUE} for no limit + * @throws IllegalArgumentException if {@code countLimit} is negative */ - public AttributeLimitsBuilder setCapacity(int capacity) { - checkArgument(capacity >= 0, "capacity must be non-negative"); - this.capacity = capacity; + public AttributeLimitsBuilder setCountLimit(int countLimit) { + checkArgument(countLimit >= 0, "countLimit must be non-negative"); + this.countLimit = countLimit; return this; } /** - * Sets the maximum length for string and string-array attribute values. Longer values are - * truncated. Applies recursively to nested {@link Value}-typed attributes. + * Sets the maximum length for string and byte-array attribute values ({@code + * AttributeValueLengthLimit}). Applies recursively to string and byte-array values within {@link + * Value}-typed and array attributes. * - * @param lengthLimit the maximum length; must be non-negative. Use {@link Integer#MAX_VALUE} for - * no length limit. + * @param valueLengthLimit non-negative maximum, or {@link Integer#MAX_VALUE} for no limit + * @throws IllegalArgumentException if {@code valueLengthLimit} is negative */ - public AttributeLimitsBuilder setLengthLimit(int lengthLimit) { - checkArgument(lengthLimit >= 0, "lengthLimit must be non-negative"); - this.lengthLimit = lengthLimit; + public AttributeLimitsBuilder setValueLengthLimit(int valueLengthLimit) { + checkArgument(valueLengthLimit >= 0, "valueLengthLimit must be non-negative"); + this.valueLengthLimit = valueLengthLimit; + return this; + } + + /** + * Sets the maximum nesting depth for array and map attribute values ({@code + * AttributeValueDepthLimit}). Depth is 1-indexed (top-level attribute value = depth 1); the limit + * must therefore be at least 1. Arrays and maps at a depth greater than the limit are replaced + * with an empty container of the same shape. + * + * @param valueDepthLimit maximum nesting depth, minimum 1, or {@link Integer#MAX_VALUE} for no + * limit + * @throws IllegalArgumentException if {@code valueDepthLimit} is less than 1 + */ + public AttributeLimitsBuilder setValueDepthLimit(int valueDepthLimit) { + checkArgument(valueDepthLimit >= 1, "valueDepthLimit must be at least 1"); + this.valueDepthLimit = valueDepthLimit; return this; } /** Builds the {@link AttributeLimits}. */ public AttributeLimits build() { - return AttributeLimits.create(capacity, lengthLimit); + return AttributeLimits.create(countLimit, valueLengthLimit, valueDepthLimit); } } diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java deleted file mode 100644 index 343d8f130a5..00000000000 --- a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeLengthLimits.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.api.internal; - -import io.opentelemetry.api.common.KeyValue; -import io.opentelemetry.api.common.Value; -import io.opentelemetry.api.common.ValueType; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Predicate; - -/** - * Truncation helpers for attribute values. Strings and byte-array values that exceed a configured - * length limit are truncated; array and key-value-list values are recursively processed. - * - *

This class is internal and is hence not for public use. Its APIs are unstable and can change - * at any time. - */ -public final class AttributeLengthLimits { - - private AttributeLengthLimits() {} - - /** - * Apply the {@code lengthLimit} to the attribute {@code value}. Strings, byte arrays, and nested - * values which exceed the length limit are truncated. - * - *

Applies to: - * - *

    - *
  • String values - *
  • Each string within an array of strings - *
  • String values within {@link Value} objects - *
  • Byte array values within {@link Value} objects - *
  • Recursively, each element in an array of {@link Value}s - *
  • Recursively, each value in a {@link Value} key-value list - *
- */ - public static Object applyAttributeLengthLimit(Object value, int lengthLimit) { - if (lengthLimit == Integer.MAX_VALUE) { - return value; - } - if (value instanceof List) { - List values = (List) value; - List response = new ArrayList<>(values.size()); - for (Object entry : values) { - response.add(applyAttributeLengthLimit(entry, lengthLimit)); - } - return response; - } - if (value instanceof String) { - String str = (String) value; - return str.length() < lengthLimit ? value : str.substring(0, lengthLimit); - } - if (value instanceof Value) { - return applyValueLengthLimit((Value) value, lengthLimit); - } - return value; - } - - /** - * Returns true if {@code value} contains no strings, byte arrays, or nested values exceeding - * {@code lengthLimit}. - */ - public static boolean isValidLength(Object value, int lengthLimit) { - if (value instanceof List) { - return allMatch((List) value, entry -> isValidLength(entry, lengthLimit)); - } else if (value instanceof String) { - return ((String) value).length() < lengthLimit; - } else if (value instanceof Value) { - return isValidLengthValue((Value) value, lengthLimit); - } - return true; - } - - private static boolean isValidLengthValue(Value value, int lengthLimit) { - ValueType type = value.getType(); - if (type == ValueType.STRING) { - return ((String) value.getValue()).length() < lengthLimit; - } else if (type == ValueType.BYTES) { - ByteBuffer buffer = (ByteBuffer) value.getValue(); - return buffer.remaining() <= lengthLimit; - } else if (type == ValueType.ARRAY) { - @SuppressWarnings("unchecked") - List> array = (List>) value.getValue(); - return allMatch(array, element -> isValidLengthValue(element, lengthLimit)); - } else if (type == ValueType.KEY_VALUE_LIST) { - @SuppressWarnings("unchecked") - List kvList = (List) value.getValue(); - return allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); - } - return true; - } - - private static boolean allMatch(Iterable iterable, Predicate predicate) { - for (T value : iterable) { - if (!predicate.test(value)) { - return false; - } - } - return true; - } - - @SuppressWarnings("unchecked") - private static Value applyValueLengthLimit(Value value, int lengthLimit) { - ValueType type = value.getType(); - - if (type == ValueType.STRING) { - String str = (String) value.getValue(); - if (str.length() <= lengthLimit) { - return value; - } - return Value.of(str.substring(0, lengthLimit)); - } else if (type == ValueType.BYTES) { - ByteBuffer buffer = (ByteBuffer) value.getValue(); - int length = buffer.remaining(); - if (length <= lengthLimit) { - return value; - } - byte[] truncated = new byte[lengthLimit]; - buffer.get(truncated); - return Value.of(truncated); - } else if (type == ValueType.ARRAY) { - List> array = (List>) value.getValue(); - boolean allValidLength = allMatch(array, element -> isValidLengthValue(element, lengthLimit)); - if (allValidLength) { - return value; - } - List> result = new ArrayList<>(array.size()); - for (Value element : array) { - result.add(applyValueLengthLimit(element, lengthLimit)); - } - return Value.of(result); - } else if (type == ValueType.KEY_VALUE_LIST) { - List kvList = (List) value.getValue(); - boolean allValidLength = - allMatch(kvList, kv -> isValidLengthValue(kv.getValue(), lengthLimit)); - if (allValidLength) { - return value; - } - List result = new ArrayList<>(kvList.size()); - for (KeyValue kv : kvList) { - result.add(KeyValue.of(kv.getKey(), applyValueLengthLimit(kv.getValue(), lengthLimit))); - } - return Value.of(result.toArray(new KeyValue[0])); - } - - // For BOOLEAN, LONG, DOUBLE - no truncation needed - return value; - } -} diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java new file mode 100644 index 00000000000..c6793aef10f --- /dev/null +++ b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java @@ -0,0 +1,232 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.internal; + +import io.opentelemetry.api.common.KeyValue; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.common.ValueType; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Applies the {@code AttributeValueLengthLimit} and {@code AttributeValueDepthLimit} rules from the + * OpenTelemetry common attribute-limits specification to a single attribute value. + * + *

Length limit: strings and byte arrays longer than the limit are truncated. Applies recursively + * to string and byte-array values within array and map ({@code Value}) attributes. + * + *

Depth limit: the top-level attribute value is at depth 1; depth increments when descending + * into array elements or map values. Arrays and maps at a depth greater than the limit are replaced + * with an empty container of the same shape. Non-array/map values pass through unchanged regardless + * of depth. + * + *

Both passes are performed in a single traversal. Values that don't require modification pass + * through by reference; no allocation is performed on the pure fast path. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class AttributeValueLimits { + + @SuppressWarnings("ExplicitArrayForVarargs") // Disambiguates against Value.of(Value...) + private static final Value EMPTY_MAP_VALUE = Value.of(new KeyValue[0]); + + private AttributeValueLimits() {} + + /** + * Returns {@code value} truncated to {@code lengthLimit} and depth-limited to {@code depthLimit}, + * or {@code value} itself if no changes are needed. + */ + public static Object apply(Object value, int lengthLimit, int depthLimit) { + if (lengthLimit == Integer.MAX_VALUE && depthLimit == Integer.MAX_VALUE) { + return value; + } + return applyAtDepth(value, 1, lengthLimit, depthLimit); + } + + /** + * Length-limit only. Retained for callers that only need value-length truncation (e.g. {@link + * io.opentelemetry.api.common.Attributes} count-limit application in the SDK). Equivalent to + * {@link #apply(Object, int, int) apply(value, lengthLimit, Integer.MAX_VALUE)}. + */ + public static Object applyLengthLimit(Object value, int lengthLimit) { + return apply(value, lengthLimit, Integer.MAX_VALUE); + } + + /** + * Returns true if {@code value} would be unchanged by {@link #applyLengthLimit(Object, int)} with + * the same limit. Used as a fast-path check to avoid materializing an unchanged {@link + * io.opentelemetry.api.common.Attributes} copy. + */ + @SuppressWarnings("ReferenceEquality") + public static boolean isValidLength(Object value, int lengthLimit) { + if (lengthLimit == Integer.MAX_VALUE) { + return true; + } + // apply returns the same instance when nothing needs truncation. + return apply(value, lengthLimit, Integer.MAX_VALUE) == value; + } + + private static Object applyAtDepth(Object value, int depth, int lengthLimit, int depthLimit) { + if (value instanceof String) { + return applyStringLength((String) value, lengthLimit); + } + if (value instanceof List) { + // Typed array attribute (List, List, ...). This is an array in the AnyValue + // sense, so depth applies at its own depth; its elements are always scalar so we don't + // recurse for depth. + if (depth > depthLimit) { + return emptyTypedList((List) value); + } + return applyListLength((List) value, lengthLimit); + } + if (value instanceof Value) { + return applyValueAtDepth((Value) value, depth, lengthLimit, depthLimit); + } + // Long, Double, Boolean and other scalars pass through unchanged. + return value; + } + + private static Object applyStringLength(String value, int lengthLimit) { + if (lengthLimit == Integer.MAX_VALUE || value.length() <= lengthLimit) { + return value; + } + return value.substring(0, lengthLimit); + } + + private static Object applyListLength(List list, int lengthLimit) { + if (lengthLimit == Integer.MAX_VALUE || list.isEmpty()) { + return list; + } + // Find the first entry that needs truncation. If none, return the input list unchanged + // (covers List, List, List entirely, and List without + // over-long entries). + int firstChangedIndex = -1; + int size = list.size(); + for (int i = 0; i < size; i++) { + Object entry = list.get(i); + if (entry instanceof String && ((String) entry).length() > lengthLimit) { + firstChangedIndex = i; + break; + } + } + if (firstChangedIndex < 0) { + return list; + } + List result = new ArrayList<>(size); + for (int i = 0; i < firstChangedIndex; i++) { + result.add(list.get(i)); + } + for (int i = firstChangedIndex; i < size; i++) { + Object entry = list.get(i); + if (entry instanceof String && ((String) entry).length() > lengthLimit) { + result.add(((String) entry).substring(0, lengthLimit)); + } else { + result.add(entry); + } + } + return result; + } + + private static Value applyValueAtDepth( + Value value, int depth, int lengthLimit, int depthLimit) { + ValueType type = value.getType(); + switch (type) { + case STRING: + { + if (lengthLimit == Integer.MAX_VALUE) { + return value; + } + String str = (String) value.getValue(); + if (str.length() <= lengthLimit) { + return value; + } + return Value.of(str.substring(0, lengthLimit)); + } + case BYTES: + { + if (lengthLimit == Integer.MAX_VALUE) { + return value; + } + ByteBuffer buffer = (ByteBuffer) value.getValue(); + if (buffer.remaining() <= lengthLimit) { + return value; + } + byte[] truncated = new byte[lengthLimit]; + buffer.get(truncated); + return Value.of(truncated); + } + case ARRAY: + return applyArrayAtDepth(value, depth, lengthLimit, depthLimit); + case KEY_VALUE_LIST: + return applyKeyValueListAtDepth(value, depth, lengthLimit, depthLimit); + default: + // BOOLEAN, LONG, DOUBLE, EMPTY - not affected by either limit. + return value; + } + } + + @SuppressWarnings({"unchecked", "ReferenceEquality"}) + private static Value applyArrayAtDepth( + Value value, int depth, int lengthLimit, int depthLimit) { + if (depth > depthLimit) { + return Value.of(Collections.>emptyList()); + } + List> elements = (List>) value.getValue(); + int size = elements.size(); + int newDepth = depth + 1; + Value[] rewritten = null; + for (int i = 0; i < size; i++) { + Value element = elements.get(i); + Value mapped = applyValueAtDepth(element, newDepth, lengthLimit, depthLimit); + if (rewritten != null) { + rewritten[i] = mapped; + } else if (mapped != element) { + rewritten = new Value[size]; + for (int j = 0; j < i; j++) { + rewritten[j] = elements.get(j); + } + rewritten[i] = mapped; + } + } + return rewritten == null ? value : Value.of(rewritten); + } + + @SuppressWarnings({"unchecked", "ReferenceEquality"}) + private static Value applyKeyValueListAtDepth( + Value value, int depth, int lengthLimit, int depthLimit) { + if (depth > depthLimit) { + return EMPTY_MAP_VALUE; + } + List kvList = (List) value.getValue(); + int size = kvList.size(); + int newDepth = depth + 1; + KeyValue[] rewritten = null; + for (int i = 0; i < size; i++) { + KeyValue kv = kvList.get(i); + Value mapped = applyValueAtDepth(kv.getValue(), newDepth, lengthLimit, depthLimit); + if (rewritten != null) { + rewritten[i] = KeyValue.of(kv.getKey(), mapped); + } else if (mapped != kv.getValue()) { + rewritten = new KeyValue[size]; + for (int j = 0; j < i; j++) { + rewritten[j] = kvList.get(j); + } + rewritten[i] = KeyValue.of(kv.getKey(), mapped); + } + } + return rewritten == null ? value : Value.of(rewritten); + } + + private static List emptyTypedList(List list) { + if (list.isEmpty()) { + return list; + } + return Collections.emptyList(); + } +} diff --git a/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java b/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java index e457f870b9a..b1e5e48a6c0 100644 --- a/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java +++ b/api/all/src/test/java/io/opentelemetry/api/common/AttributeLimitsTest.java @@ -15,8 +15,9 @@ class AttributeLimitsTest { @Test void noLimits_returnsUnbounded() { AttributeLimits limits = AttributeLimits.noLimits(); - assertThat(limits.getCapacity()).isEqualTo(Integer.MAX_VALUE); - assertThat(limits.getLengthLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getCountLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getValueLengthLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getValueDepthLimit()).isEqualTo(Integer.MAX_VALUE); } @Test @@ -27,41 +28,102 @@ void noLimits_isCached() { @Test void builder_defaultsToUnbounded() { AttributeLimits limits = AttributeLimits.builder().build(); - assertThat(limits.getCapacity()).isEqualTo(Integer.MAX_VALUE); - assertThat(limits.getLengthLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getCountLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getValueLengthLimit()).isEqualTo(Integer.MAX_VALUE); + assertThat(limits.getValueDepthLimit()).isEqualTo(Integer.MAX_VALUE); } @Test void builder_setsFields() { AttributeLimits limits = - AttributeLimits.builder().setCapacity(128).setLengthLimit(1024).build(); - assertThat(limits.getCapacity()).isEqualTo(128); - assertThat(limits.getLengthLimit()).isEqualTo(1024); + AttributeLimits.builder() + .setCountLimit(128) + .setValueLengthLimit(1024) + .setValueDepthLimit(64) + .build(); + assertThat(limits.getCountLimit()).isEqualTo(128); + assertThat(limits.getValueLengthLimit()).isEqualTo(1024); + assertThat(limits.getValueDepthLimit()).isEqualTo(64); } @Test - void builder_rejectsNegative() { + void builder_rejectsNegativeCount() { assertThatIllegalArgumentException() - .isThrownBy(() -> AttributeLimits.builder().setCapacity(-1)); + .isThrownBy(() -> AttributeLimits.builder().setCountLimit(-1)); + } + + @Test + void builder_rejectsNegativeValueLength() { assertThatIllegalArgumentException() - .isThrownBy(() -> AttributeLimits.builder().setLengthLimit(-1)); + .isThrownBy(() -> AttributeLimits.builder().setValueLengthLimit(-1)); + } + + @Test + void builder_acceptsZeroForCountAndLength() { + // Spec-consistent: count=0 drops everything, length=0 truncates all strings to empty. + AttributeLimits limits = + AttributeLimits.builder().setCountLimit(0).setValueLengthLimit(0).build(); + assertThat(limits.getCountLimit()).isEqualTo(0); + assertThat(limits.getValueLengthLimit()).isEqualTo(0); + } + + @Test + void builder_rejectsDepthBelowOne() { + // Depth is 1-indexed (top-level = depth 1), so a limit below 1 is meaningless. + assertThatIllegalArgumentException() + .isThrownBy(() -> AttributeLimits.builder().setValueDepthLimit(0)); + assertThatIllegalArgumentException() + .isThrownBy(() -> AttributeLimits.builder().setValueDepthLimit(-1)); + } + + @Test + void builder_acceptsDepthOne() { + AttributeLimits limits = AttributeLimits.builder().setValueDepthLimit(1).build(); + assertThat(limits.getValueDepthLimit()).isEqualTo(1); } @Test void toBuilder_preservesFields() { AttributeLimits original = - AttributeLimits.builder().setCapacity(64).setLengthLimit(512).build(); + AttributeLimits.builder() + .setCountLimit(64) + .setValueLengthLimit(512) + .setValueDepthLimit(8) + .build(); AttributeLimits copy = original.toBuilder().build(); - assertThat(copy.getCapacity()).isEqualTo(64); - assertThat(copy.getLengthLimit()).isEqualTo(512); + assertThat(copy.getCountLimit()).isEqualTo(64); + assertThat(copy.getValueLengthLimit()).isEqualTo(512); + assertThat(copy.getValueDepthLimit()).isEqualTo(8); } @Test void equals_valueBased() { - AttributeLimits a = AttributeLimits.builder().setCapacity(10).setLengthLimit(20).build(); - AttributeLimits b = AttributeLimits.builder().setCapacity(10).setLengthLimit(20).build(); - AttributeLimits c = AttributeLimits.builder().setCapacity(10).setLengthLimit(21).build(); + AttributeLimits a = + AttributeLimits.builder() + .setCountLimit(10) + .setValueLengthLimit(20) + .setValueDepthLimit(3) + .build(); + AttributeLimits b = + AttributeLimits.builder() + .setCountLimit(10) + .setValueLengthLimit(20) + .setValueDepthLimit(3) + .build(); + AttributeLimits c = + AttributeLimits.builder() + .setCountLimit(10) + .setValueLengthLimit(21) + .setValueDepthLimit(3) + .build(); + AttributeLimits d = + AttributeLimits.builder() + .setCountLimit(10) + .setValueLengthLimit(20) + .setValueDepthLimit(4) + .build(); assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); assertThat(a).isNotEqualTo(c); + assertThat(a).isNotEqualTo(d); } } diff --git a/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java index 4753713b136..1e3fbe3ccd5 100644 --- a/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java +++ b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java @@ -6,10 +6,16 @@ package io.opentelemetry.api.common; import static io.opentelemetry.api.common.AttributeKey.booleanKey; +import static io.opentelemetry.api.common.AttributeKey.longArrayKey; import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.api.common.AttributeKey.valueKey; import static org.assertj.core.api.Assertions.assertThat; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; /** @@ -17,6 +23,8 @@ */ class BoundedAttributesBuilderTest { + // ---- count limit ---- + @Test void noLimits_equivalentToDefaultBuilder() { Attributes attrs = @@ -30,7 +38,7 @@ void noLimits_equivalentToDefaultBuilder() { @Test void sameNameDifferentType_lastValueWins() { Attributes attrs = - Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + Attributes.builder(AttributeLimits.builder().setCountLimit(10).build()) .put(stringKey("k"), "hello") .put(booleanKey("k"), true) .build(); @@ -42,7 +50,7 @@ void sameNameDifferentType_lastValueWins() { @Test void sameNameDifferentType_doesNotConsumeExtraCapacity() { Attributes attrs = - Attributes.builder(AttributeLimits.builder().setCapacity(2).build()) + Attributes.builder(AttributeLimits.builder().setCountLimit(2).build()) .put(stringKey("a"), "v1") .put(booleanKey("a"), false) .put(longKey("b"), 42L) @@ -53,9 +61,9 @@ void sameNameDifferentType_doesNotConsumeExtraCapacity() { } @Test - void capacityDropsOverflow() { + void countLimit_dropsOverflow() { Attributes attrs = - Attributes.builder(AttributeLimits.builder().setCapacity(2).build()) + Attributes.builder(AttributeLimits.builder().setCountLimit(2).build()) .put(stringKey("a"), "v1") .put(stringKey("b"), "v2") .put(stringKey("c"), "v3") @@ -64,19 +72,146 @@ void capacityDropsOverflow() { assertThat(attrs.get(stringKey("c"))).isNull(); } + // ---- value length limit ---- + @Test - void lengthLimitTruncatesStringValues() { + void valueLengthLimit_truncatesStringValues() { Attributes attrs = - Attributes.builder(AttributeLimits.builder().setLengthLimit(3).build()) + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(3).build()) .put(stringKey("k"), "hello") .build(); assertThat(attrs.get(stringKey("k"))).isEqualTo("hel"); } + @Test + void valueLengthLimit_atExactBoundary_notTruncated() { + // Regression: length == limit should not allocate/truncate (spec says "at most equal to"). + String input = "hel"; + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(3).build()) + .put(stringKey("k"), input) + .build(); + assertThat(attrs.get(stringKey("k"))).isSameAs(input); + } + + @Test + void valueLengthLimit_stringArrayTruncatesEntries() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(2).build()) + .put(stringArrayKey("k"), Arrays.asList("aaa", "bb", "cccc")) + .build(); + assertThat(attrs.get(stringArrayKey("k"))).containsExactly("aa", "bb", "cc"); + } + + @Test + void valueLengthLimit_numericArrayUntouched() { + // Length limit only applies to strings and byte arrays; numeric arrays must not allocate. + List input = Arrays.asList(1L, 2L, 3L); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(1).build()) + .put(longArrayKey("k"), input) + .build(); + assertThat(attrs.get(longArrayKey("k"))).isSameAs(input); + } + + // ---- value depth limit ---- + + @Test + void valueDepthLimit_topLevelListAtDepthOne_kept() { + // depth=1 means depth > 1 is replaced. Top-level list IS depth 1, so kept unchanged. + List input = Arrays.asList("a", "b"); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(1).build()) + .put(stringArrayKey("k"), input) + .build(); + assertThat(attrs.get(stringArrayKey("k"))).isSameAs(input); + } + + @Test + void valueDepthLimit_nestedArrayReplacedWithEmpty() { + // Value containing Value. Outer at depth 1, inner at depth 2. Limit=1 → + // inner replaced with empty array. + Value nested = Value.of(Value.of("a"), Value.of("b")); + Value outer = Value.of(Value.of("x"), nested); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(1).build()) + .put(valueKey("k"), outer) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ValueType.ARRAY); + @SuppressWarnings("unchecked") + List> elements = (List>) result.getValue(); + assertThat(elements).hasSize(2); + assertThat(elements.get(0).getValue()).isEqualTo("x"); + // Inner (at depth 2) replaced with empty array. + assertThat(elements.get(1).getType()).isEqualTo(ValueType.ARRAY); + @SuppressWarnings("unchecked") + List> innerElements = (List>) elements.get(1).getValue(); + assertThat(innerElements).isEmpty(); + } + + @Test + void valueDepthLimit_nestedMapReplacedWithEmpty() { + Value innerMap = Value.of(KeyValue.of("inner", Value.of("v"))); + Value outerMap = Value.of(KeyValue.of("nested", innerMap)); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(1).build()) + .put(valueKey("k"), outerMap) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ValueType.KEY_VALUE_LIST); + @SuppressWarnings("unchecked") + List kvList = (List) result.getValue(); + assertThat(kvList).hasSize(1); + assertThat(kvList.get(0).getKey()).isEqualTo("nested"); + Value innerResult = kvList.get(0).getValue(); + assertThat(innerResult.getType()).isEqualTo(ValueType.KEY_VALUE_LIST); + @SuppressWarnings("unchecked") + List innerList = (List) innerResult.getValue(); + assertThat(innerList).isEmpty(); + } + + @Test + void valueDepthLimit_unchangedPassThrough() { + // limit well above the nesting depth: nothing should change. + Value inner = Value.of(Value.of("x")); + Value outer = Value.of(inner); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(100).build()) + .put(valueKey("k"), outer) + .build(); + assertThat(attrs.get(valueKey("k"))).isSameAs(outer); + } + + // ---- combined length + depth ---- + + @Test + void combined_lengthAndDepth() { + Value nested = Value.of(Value.of("very-long-string")); + Value outer = Value.of(Value.of("also-long"), nested); + Attributes attrs = + Attributes.builder( + AttributeLimits.builder().setValueLengthLimit(4).setValueDepthLimit(1).build()) + .put(valueKey("k"), outer) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + @SuppressWarnings("unchecked") + List> elements = (List>) result.getValue(); + // First element: a string at depth 2, truncated to 4 chars. + assertThat(elements.get(0).getValue()).isEqualTo("also"); + // Second element: an array at depth 2, replaced with empty array. + assertThat(elements.get(1).getValue()).isEqualTo(Collections.emptyList()); + } + + // ---- build caching ---- + @Test void buildIsCached_returnsSameInstanceBetweenMutations() { AttributesBuilder builder = - Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + Attributes.builder(AttributeLimits.builder().setCountLimit(10).build()) .put(stringKey("a"), "v"); Attributes first = builder.build(); Attributes second = builder.build(); @@ -90,7 +225,7 @@ void buildIsCached_returnsSameInstanceBetweenMutations() { @Test void remove_invalidatesCache() { AttributesBuilder builder = - Attributes.builder(AttributeLimits.builder().setCapacity(10).build()) + Attributes.builder(AttributeLimits.builder().setCountLimit(10).build()) .put(stringKey("a"), "v1") .put(stringKey("b"), "v2"); Attributes first = builder.build(); diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java index b89746df97d..95e874fe121 100644 --- a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributeUtil.java @@ -8,7 +8,7 @@ import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; -import io.opentelemetry.api.internal.AttributeLengthLimits; +import io.opentelemetry.api.internal.AttributeValueLimits; import java.util.Map; /** @@ -35,7 +35,7 @@ public static Attributes applyAttributesLimit( } boolean allValidLength = true; for (Object value : attributes.asMap().values()) { - if (!AttributeLengthLimits.isValidLength(value, lengthLimit)) { + if (!AttributeValueLimits.isValidLength(value, lengthLimit)) { allValidLength = false; break; } @@ -53,7 +53,7 @@ public static Attributes applyAttributesLimit( } result.put( (AttributeKey) entry.getKey(), - AttributeLengthLimits.applyAttributeLengthLimit(entry.getValue(), lengthLimit)); + AttributeValueLimits.applyLengthLimit(entry.getValue(), lengthLimit)); i++; } return result.build(); diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java index 665cad81e2f..48a307d0b09 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java @@ -133,8 +133,8 @@ public SdkLogRecordBuilder setAttribute(AttributeKey key, @Nullable T val this.attributes = Attributes.builder( AttributeLimits.builder() - .setCapacity(logLimits.getMaxNumberOfAttributes()) - .setLengthLimit(logLimits.getMaxAttributeValueLength()) + .setCountLimit(logLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(logLimits.getMaxAttributeValueLength()) .build()); } totalAttributeCount++; diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java index ef27901e670..1b4476229f4 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkReadWriteLogRecord.java @@ -107,8 +107,8 @@ public ReadWriteLogRecord setAttribute(AttributeKey key, T value) { attributes = Attributes.builder( AttributeLimits.builder() - .setCapacity(logLimits.getMaxNumberOfAttributes()) - .setLengthLimit(logLimits.getMaxAttributeValueLength()) + .setCountLimit(logLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(logLimits.getMaxAttributeValueLength()) .build()); } totalAttributeCount++; diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java index 3d720af49f3..8257ceb112e 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java @@ -52,7 +52,8 @@ void allHandlesEmpty() { SdkReadWriteLogRecord buildLogRecord() { Value body = Value.of("bod"); AttributesBuilder initialAttributes = - Attributes.builder(AttributeLimits.builder().setCapacity(100).setLengthLimit(200).build()); + Attributes.builder( + AttributeLimits.builder().setCountLimit(100).setValueLengthLimit(200).build()); initialAttributes.put(stringKey("foo"), "aaiosjfjioasdiojfjioasojifja"); initialAttributes.put(stringKey("untouched"), "yes"); LogLimits limits = LogLimits.getDefault(); diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java index 155275e4cee..997ed8b70e6 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java @@ -361,8 +361,8 @@ public ReadWriteSpan setAttribute(AttributeKey key, @Nullable T value) { private static AttributeLimits attributeLimits(SpanLimits spanLimits) { return AttributeLimits.builder() - .setCapacity(spanLimits.getMaxNumberOfAttributes()) - .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .setCountLimit(spanLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(spanLimits.getMaxAttributeValueLength()) .build(); } diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java index eeff972eefb..1e9e999f030 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java @@ -290,8 +290,8 @@ private AttributesBuilder attributes() { this.attributes = Attributes.builder( AttributeLimits.builder() - .setCapacity(spanLimits.getMaxNumberOfAttributes()) - .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .setCountLimit(spanLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(spanLimits.getMaxAttributeValueLength()) .build()); attributes = this.attributes; } diff --git a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java index 3cd81b32e69..bd1bdf2eb08 100644 --- a/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java +++ b/sdk/trace/src/test/java/io/opentelemetry/sdk/trace/SdkSpanTest.java @@ -1410,8 +1410,8 @@ void onStartOnEndNotRequired() { resource, Attributes.builder( AttributeLimits.builder() - .setCapacity(spanLimits.getMaxNumberOfAttributes()) - .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .setCountLimit(spanLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(spanLimits.getMaxAttributeValueLength()) .build()), 0, Collections.emptyList(), @@ -1493,8 +1493,8 @@ private SdkSpan createTestSpanWithAttributes(Map attribute AttributesBuilder builder = Attributes.builder( AttributeLimits.builder() - .setCapacity(spanLimits.getMaxNumberOfAttributes()) - .setLengthLimit(spanLimits.getMaxAttributeValueLength()) + .setCountLimit(spanLimits.getMaxNumberOfAttributes()) + .setValueLengthLimit(spanLimits.getMaxAttributeValueLength()) .build()); @SuppressWarnings("unchecked") Map, Object> raw = (Map, Object>) (Map) attributes; @@ -1634,7 +1634,7 @@ void testAsSpanData() { Resource resource = this.resource; Attributes attributes = TestUtils.generateRandomAttributes(); AttributesBuilder attributesWithCapacity = - Attributes.builder(AttributeLimits.builder().setCapacity(32).build()); + Attributes.builder(AttributeLimits.builder().setCountLimit(32).build()); attributesWithCapacity.putAll(attributes); Attributes builtAttributes = attributesWithCapacity.build(); Attributes event1Attributes = TestUtils.generateRandomAttributes(); From 7b2d12d8bf67e3a76f7c5bbdfd8ba87ee6e8ed8d Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:06:59 -0500 Subject: [PATCH 3/6] Extend attributes benchmark --- .../api/common/AttributesBenchmark.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/all/src/jmh/java/io/opentelemetry/api/common/AttributesBenchmark.java b/api/all/src/jmh/java/io/opentelemetry/api/common/AttributesBenchmark.java index 8b3d16425fc..b19a64855b7 100644 --- a/api/all/src/jmh/java/io/opentelemetry/api/common/AttributesBenchmark.java +++ b/api/all/src/jmh/java/io/opentelemetry/api/common/AttributesBenchmark.java @@ -121,6 +121,16 @@ public Attributes ofFive() { values.get(4)); } + @Benchmark + @BenchmarkMode({Mode.AverageTime}) + @Fork(1) + @Measurement(iterations = 15, time = 1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + @Warmup(iterations = 5, time = 1) + public Attributes builderOneItem() { + return Attributes.builder().put(keys.get(0), values.get(0)).build(); + } + @Benchmark @BenchmarkMode({Mode.AverageTime}) @Fork(1) @@ -134,4 +144,14 @@ public Attributes builderTenItems() { } return attributesBuilder.build(); } + + @Benchmark + @BenchmarkMode({Mode.AverageTime}) + @Fork(1) + @Measurement(iterations = 15, time = 1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + @Warmup(iterations = 5, time = 1) + public Attributes putAllTenItems() { + return Attributes.builder().putAll(attributes.get(9)).build(); + } } From 94efe28d79c1b15216f55460a250d59f093b8278 Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:07:09 -0500 Subject: [PATCH 4/6] Fix build --- .../java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java index 0397ca404b4..99c9e4db0e2 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/ReadWriteLogRecordTest.java @@ -8,8 +8,8 @@ import static io.opentelemetry.api.common.AttributeKey.stringKey; import static org.assertj.core.api.Assertions.assertThat; -import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeLimits; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; From 0be54baa913b30272012fbcbcc7c0f0e547d0e30 Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:27:05 -0500 Subject: [PATCH 5/6] Cleanup, improve coverage --- .../common/ArrayBackedAttributesBuilder.java | 21 +--- .../api/common/AttributeLimits.java | 4 - .../opentelemetry/api/common/Attributes.java | 9 +- .../api/internal/AttributeValueLimits.java | 30 ++--- .../common/BoundedAttributesBuilderTest.java | 113 +++++++++++++++++- .../internal/AttributeValueLimitsTest.java | 77 ++++++++++++ .../io/opentelemetry/sdk/trace/SdkSpan.java | 2 - 7 files changed, 203 insertions(+), 53 deletions(-) create mode 100644 api/all/src/test/java/io/opentelemetry/api/internal/AttributeValueLimitsTest.java diff --git a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java index 9deeb50f0ae..c2eeae6d132 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/ArrayBackedAttributesBuilder.java @@ -27,26 +27,18 @@ class ArrayBackedAttributesBuilder implements AttributesBuilder { /** Max number of unique entries. {@link Integer#MAX_VALUE} means unlimited. */ private final int countLimit; - /** - * Max length of string / byte-array values. {@link Integer#MAX_VALUE} means unlimited. Only - * consulted on the limited put path. - */ + /** Max length of string / byte-array values. {@link Integer#MAX_VALUE} means unlimited. */ private final int valueLengthLimit; - /** - * Max nesting depth for array / map values. {@link Integer#MAX_VALUE} means unlimited. Only - * consulted on the limited put path. - */ + /** Max nesting depth for array / map values. {@link Integer#MAX_VALUE} means unlimited. */ private final int valueDepthLimit; /** Count of non-null entries currently stored (excludes null holes from remove). */ private int size; /** - * Cached {@link #build()} result, invalidated by any mutation. Speeds up repeated read-through - * calls (e.g. {@code AttributesBuilder}-as-live-view for SDK span attributes). Only maintained in - * the limited configuration since the unlimited path defers dedup to {@link #build()} and has no - * need for a live view. + * Cached {@link #build()} result; null when unset or after a mutation. Only maintained when + * limits are configured. */ @Nullable private Attributes cachedBuild; @@ -124,10 +116,7 @@ public AttributesBuilder put(AttributeKey key, @Nullable T value) { return this; } - /** - * Store the given key/value pair. In unlimited mode: append. In limited mode: dedup by name - * (last-value-wins), truncate over-length values, and drop entries beyond capacity. - */ + /** Append (unlimited) or dedup-by-name / truncate / capacity-check (limited). */ private void addPair(AttributeKey key, Object value) { if (!isLimited()) { data.add(key); diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java index d18d0127140..28a298da45d 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java @@ -25,9 +25,6 @@ * href="https://github.com/open-telemetry/opentelemetry-specification/tree/main/specification/common#attribute-limits">common * attribute-limits specification. * - *

Use {@link #noLimits()} to represent an unbounded configuration and {@link #builder()} to - * construct a bounded one. - * * @since 1.64.0 */ public abstract class AttributeLimits { @@ -49,7 +46,6 @@ static AttributeLimits create(int countLimit, int valueLengthLimit, int valueDep countLimit, valueLengthLimit, valueDepthLimit); } - /** Package-private constructor to prevent subclassing. */ AttributeLimits() {} /** diff --git a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java index 71a936cbf24..2f81f625a6c 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java @@ -222,13 +222,8 @@ static AttributesBuilder builder() { } /** - * Returns a new {@link AttributesBuilder} instance that enforces the given {@link - * AttributeLimits}. The returned builder applies last-value-wins semantics on {@link - * AttributesBuilder#put put} (by {@link AttributeKey#getKey() key name}, regardless of {@link - * AttributeType}), truncates over-length string values, and drops entries added beyond the - * configured capacity. - * - *

Passing {@link AttributeLimits#noLimits()} is functionally equivalent to {@link #builder()}. + * Returns a new {@link AttributesBuilder} that enforces the given {@link AttributeLimits}. + * Passing {@link AttributeLimits#noLimits()} is equivalent to {@link #builder()}. * * @since 1.64.0 */ diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java index c6793aef10f..5c720fdc962 100644 --- a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java +++ b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java @@ -15,15 +15,9 @@ /** * Applies the {@code AttributeValueLengthLimit} and {@code AttributeValueDepthLimit} rules from the - * OpenTelemetry common attribute-limits specification to a single attribute value. - * - *

Length limit: strings and byte arrays longer than the limit are truncated. Applies recursively - * to string and byte-array values within array and map ({@code Value}) attributes. - * - *

Depth limit: the top-level attribute value is at depth 1; depth increments when descending - * into array elements or map values. Arrays and maps at a depth greater than the limit are replaced - * with an empty container of the same shape. Non-array/map values pass through unchanged regardless - * of depth. + * OpenTelemetry common attribute-limits specification + * to a single attribute value. * *

Both passes are performed in a single traversal. Values that don't require modification pass * through by reference; no allocation is performed on the pure fast path. @@ -50,9 +44,7 @@ public static Object apply(Object value, int lengthLimit, int depthLimit) { } /** - * Length-limit only. Retained for callers that only need value-length truncation (e.g. {@link - * io.opentelemetry.api.common.Attributes} count-limit application in the SDK). Equivalent to - * {@link #apply(Object, int, int) apply(value, lengthLimit, Integer.MAX_VALUE)}. + * Equivalent to {@link #apply(Object, int, int) apply(value, lengthLimit, Integer.MAX_VALUE)}. */ public static Object applyLengthLimit(Object value, int lengthLimit) { return apply(value, lengthLimit, Integer.MAX_VALUE); @@ -60,15 +52,13 @@ public static Object applyLengthLimit(Object value, int lengthLimit) { /** * Returns true if {@code value} would be unchanged by {@link #applyLengthLimit(Object, int)} with - * the same limit. Used as a fast-path check to avoid materializing an unchanged {@link - * io.opentelemetry.api.common.Attributes} copy. + * the same limit. */ @SuppressWarnings("ReferenceEquality") public static boolean isValidLength(Object value, int lengthLimit) { if (lengthLimit == Integer.MAX_VALUE) { return true; } - // apply returns the same instance when nothing needs truncation. return apply(value, lengthLimit, Integer.MAX_VALUE) == value; } @@ -77,9 +67,7 @@ private static Object applyAtDepth(Object value, int depth, int lengthLimit, int return applyStringLength((String) value, lengthLimit); } if (value instanceof List) { - // Typed array attribute (List, List, ...). This is an array in the AnyValue - // sense, so depth applies at its own depth; its elements are always scalar so we don't - // recurse for depth. + // Typed array; elements are always scalar so we don't recurse for depth. if (depth > depthLimit) { return emptyTypedList((List) value); } @@ -88,7 +76,6 @@ private static Object applyAtDepth(Object value, int depth, int lengthLimit, int if (value instanceof Value) { return applyValueAtDepth((Value) value, depth, lengthLimit, depthLimit); } - // Long, Double, Boolean and other scalars pass through unchanged. return value; } @@ -103,9 +90,7 @@ private static Object applyListLength(List list, int lengthLimit) { if (lengthLimit == Integer.MAX_VALUE || list.isEmpty()) { return list; } - // Find the first entry that needs truncation. If none, return the input list unchanged - // (covers List, List, List entirely, and List without - // over-long entries). + // Two-pass so numeric-only lists (and short-string lists) return unchanged with no allocation. int firstChangedIndex = -1; int size = list.size(); for (int i = 0; i < size; i++) { @@ -166,7 +151,6 @@ private static Value applyValueAtDepth( case KEY_VALUE_LIST: return applyKeyValueListAtDepth(value, depth, lengthLimit, depthLimit); default: - // BOOLEAN, LONG, DOUBLE, EMPTY - not affected by either limit. return value; } } diff --git a/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java index 1e3fbe3ccd5..5e43c8e2844 100644 --- a/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java +++ b/api/all/src/test/java/io/opentelemetry/api/common/BoundedAttributesBuilderTest.java @@ -13,6 +13,7 @@ import static io.opentelemetry.api.common.AttributeKey.valueKey; import static org.assertj.core.api.Assertions.assertThat; +import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -103,6 +104,62 @@ void valueLengthLimit_stringArrayTruncatesEntries() { assertThat(attrs.get(stringArrayKey("k"))).containsExactly("aa", "bb", "cc"); } + @Test + void valueLengthLimit_zeroTruncatesStringsToEmpty() { + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(0).build()) + .put(stringKey("k"), "hello") + .build(); + assertThat(attrs.get(stringKey("k"))).isEqualTo(""); + } + + @Test + void valueLengthLimit_truncatesBytes() { + Value bytes = Value.of(new byte[] {1, 2, 3, 4, 5}); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(3).build()) + .put(valueKey("k"), bytes) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ValueType.BYTES); + ByteBuffer resultBuf = (ByteBuffer) result.getValue(); + byte[] resultBytes = new byte[resultBuf.remaining()]; + resultBuf.slice().get(resultBytes); + assertThat(resultBytes).containsExactly(1, 2, 3); + } + + @Test + void valueLengthLimit_truncatesStringsInsideValueArray() { + Value array = Value.of(Value.of("short"), Value.of("way-too-long")); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(5).build()) + .put(valueKey("k"), array) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + @SuppressWarnings("unchecked") + List> elements = (List>) result.getValue(); + assertThat(elements.get(0).getValue()).isEqualTo("short"); + assertThat(elements.get(1).getValue()).isEqualTo("way-t"); + } + + @Test + void valueLengthLimit_truncatesStringsInsideValueMap() { + Value map = + Value.of(KeyValue.of("a", Value.of("ok")), KeyValue.of("b", Value.of("way-too-long"))); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueLengthLimit(3).build()) + .put(valueKey("k"), map) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + @SuppressWarnings("unchecked") + List kvList = (List) result.getValue(); + assertThat(kvList.get(0).getValue().getValue()).isEqualTo("ok"); + assertThat(kvList.get(1).getValue().getValue()).isEqualTo("way"); + } + @Test void valueLengthLimit_numericArrayUntouched() { // Length limit only applies to strings and byte arrays; numeric arrays must not allocate. @@ -144,7 +201,6 @@ void valueDepthLimit_nestedArrayReplacedWithEmpty() { List> elements = (List>) result.getValue(); assertThat(elements).hasSize(2); assertThat(elements.get(0).getValue()).isEqualTo("x"); - // Inner (at depth 2) replaced with empty array. assertThat(elements.get(1).getType()).isEqualTo(ValueType.ARRAY); @SuppressWarnings("unchecked") List> innerElements = (List>) elements.get(1).getValue(); @@ -173,6 +229,61 @@ void valueDepthLimit_nestedMapReplacedWithEmpty() { assertThat(innerList).isEmpty(); } + @Test + void valueDepthLimit_atExactBoundary_kept() { + // limit=2 → depth 2 kept (depth > limit replaces), depth 3 would be replaced. + Value inner = Value.of(Value.of("x")); + Value outer = Value.of(inner); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(2).build()) + .put(valueKey("k"), outer) + .build(); + assertThat(attrs.get(valueKey("k"))).isSameAs(outer); + } + + @Test + void valueDepthLimit_threeLevelNesting_middleKeptInnermostReplaced() { + Value level3 = Value.of(Value.of("deep")); + Value level2 = Value.of(level3); + Value level1 = Value.of(level2); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(2).build()) + .put(valueKey("k"), level1) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + @SuppressWarnings("unchecked") + List> outer = (List>) result.getValue(); + assertThat(outer).hasSize(1); + assertThat(outer.get(0).getType()).isEqualTo(ValueType.ARRAY); + @SuppressWarnings("unchecked") + List> middle = (List>) outer.get(0).getValue(); + assertThat(middle).hasSize(1); + assertThat(middle.get(0).getType()).isEqualTo(ValueType.ARRAY); + @SuppressWarnings("unchecked") + List> innermost = (List>) middle.get(0).getValue(); + assertThat(innermost).isEmpty(); + } + + @Test + void valueDepthLimit_emptyArrayAtExcessDepthStillReplaced() { + Value emptyInner = Value.of(Collections.>emptyList()); + Value outer = Value.of(emptyInner); + Attributes attrs = + Attributes.builder(AttributeLimits.builder().setValueDepthLimit(1).build()) + .put(valueKey("k"), outer) + .build(); + Value result = attrs.get(valueKey("k")); + assertThat(result).isNotNull(); + @SuppressWarnings("unchecked") + List> elements = (List>) result.getValue(); + assertThat(elements).hasSize(1); + assertThat(elements.get(0).getType()).isEqualTo(ValueType.ARRAY); + @SuppressWarnings("unchecked") + List> inner = (List>) elements.get(0).getValue(); + assertThat(inner).isEmpty(); + } + @Test void valueDepthLimit_unchangedPassThrough() { // limit well above the nesting depth: nothing should change. diff --git a/api/all/src/test/java/io/opentelemetry/api/internal/AttributeValueLimitsTest.java b/api/all/src/test/java/io/opentelemetry/api/internal/AttributeValueLimitsTest.java new file mode 100644 index 00000000000..4456da0179d --- /dev/null +++ b/api/all/src/test/java/io/opentelemetry/api/internal/AttributeValueLimitsTest.java @@ -0,0 +1,77 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.KeyValue; +import io.opentelemetry.api.common.Value; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class AttributeValueLimitsTest { + + @Test + void isValidLength_unlimited_alwaysTrue() { + assertThat(AttributeValueLimits.isValidLength("anything", Integer.MAX_VALUE)).isTrue(); + assertThat(AttributeValueLimits.isValidLength(Arrays.asList("a", "b"), Integer.MAX_VALUE)) + .isTrue(); + } + + @Test + void isValidLength_stringWithinLimit() { + assertThat(AttributeValueLimits.isValidLength("abc", 3)).isTrue(); + assertThat(AttributeValueLimits.isValidLength("ab", 3)).isTrue(); + } + + @Test + void isValidLength_stringOverLimit() { + assertThat(AttributeValueLimits.isValidLength("abcd", 3)).isFalse(); + } + + @Test + void isValidLength_numericScalar_alwaysTrue() { + assertThat(AttributeValueLimits.isValidLength(42L, 0)).isTrue(); + assertThat(AttributeValueLimits.isValidLength(3.14, 0)).isTrue(); + assertThat(AttributeValueLimits.isValidLength(true, 0)).isTrue(); + } + + @Test + void isValidLength_stringArrayWithinLimit() { + assertThat(AttributeValueLimits.isValidLength(Arrays.asList("ab", "cd"), 3)).isTrue(); + } + + @Test + void isValidLength_stringArrayWithOverLongEntry() { + assertThat(AttributeValueLimits.isValidLength(Arrays.asList("ok", "way-too-long"), 3)) + .isFalse(); + } + + @Test + void isValidLength_nestedValueWithOverLongString() { + Value nested = Value.of(Value.of("way-too-long")); + assertThat(AttributeValueLimits.isValidLength(nested, 4)).isFalse(); + } + + @Test + void isValidLength_nestedMapWithOverLongString() { + Value map = Value.of(KeyValue.of("k", Value.of("way-too-long"))); + assertThat(AttributeValueLimits.isValidLength(map, 4)).isFalse(); + } + + @Test + void applyLengthLimit_returnsInputWhenUnchanged() { + String input = "abc"; + assertThat(AttributeValueLimits.applyLengthLimit(input, 5)).isSameAs(input); + } + + @Test + void apply_unlimited_returnsInput() { + Value value = Value.of(Value.of("anything")); + assertThat(AttributeValueLimits.apply(value, Integer.MAX_VALUE, Integer.MAX_VALUE)) + .isSameAs(value); + } +} diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java index 997ed8b70e6..9b03fe12a7f 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpan.java @@ -648,8 +648,6 @@ private Attributes getImmutableAttributes() { if (attributes == null) { return Attributes.empty(); } - // The builder caches its build() result until the next mutation, so this is cheap on repeated - // calls (including when the span has ended and no further mutations can happen). return attributes.build(); } From 4c65a22c3d60b03b17d9b2fa05925520670bc592 Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:49:38 -0500 Subject: [PATCH 6/6] Remove since, add TODOs --- .../io/opentelemetry/api/common/AttributeLimits.java | 2 -- .../api/common/AttributeLimitsBuilder.java | 12 +++++++----- .../java/io/opentelemetry/api/common/Attributes.java | 2 -- .../api/internal/AttributeValueLimits.java | 5 +++++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java index 28a298da45d..7657078f404 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimits.java @@ -24,8 +24,6 @@ * OpenTelemetry common * attribute-limits specification. - * - * @since 1.64.0 */ public abstract class AttributeLimits { diff --git a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java index 8ac07b95c7c..8e3def30a00 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/AttributeLimitsBuilder.java @@ -7,15 +7,17 @@ import static io.opentelemetry.api.internal.Utils.checkArgument; -/** - * Builder for {@link AttributeLimits}. - * - * @since 1.64.0 - */ +/** Builder for {@link AttributeLimits}. */ public final class AttributeLimitsBuilder { private int countLimit = Integer.MAX_VALUE; private int valueLengthLimit = Integer.MAX_VALUE; + + // TODO(jack-berg): before merging, decide whether to default this to 64 (spec-recommended, + // matches System.Text.Json, provides stack safety when callers set a length limit but forget + // depth). Since depth is net new we can pick a non-infinite default without breaking anyone; + // count and length must stay at Integer.MAX_VALUE for back-compat. Would diverge builder + // defaults from AttributeLimits.noLimits(). private int valueDepthLimit = Integer.MAX_VALUE; AttributeLimitsBuilder() {} diff --git a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java index 2f81f625a6c..69442bec743 100644 --- a/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java +++ b/api/all/src/main/java/io/opentelemetry/api/common/Attributes.java @@ -224,8 +224,6 @@ static AttributesBuilder builder() { /** * Returns a new {@link AttributesBuilder} that enforces the given {@link AttributeLimits}. * Passing {@link AttributeLimits#noLimits()} is equivalent to {@link #builder()}. - * - * @since 1.64.0 */ static AttributesBuilder builder(AttributeLimits limits) { return new ArrayBackedAttributesBuilder(limits); diff --git a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java index 5c720fdc962..49792c4b6a6 100644 --- a/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java +++ b/api/all/src/main/java/io/opentelemetry/api/internal/AttributeValueLimits.java @@ -118,6 +118,11 @@ private static Object applyListLength(List list, int lengthLimit) { return result; } + // TODO(jack-berg): convert to iterative traversal (explicit stack) to guarantee no + // StackOverflowError on deeply-nested input, regardless of caller configuration. Today the + // recursion is bounded by depthLimit + 1 when depth is finite; a caller that sets a length + // limit but leaves depth unlimited (Integer.MAX_VALUE) recurses to the input's actual nesting + // depth. Pre-existing behavior from AttributeLengthLimits, not introduced by depth support. private static Value applyValueAtDepth( Value value, int depth, int lengthLimit, int depthLimit) { ValueType type = value.getType();