+ *
+ * @param value the percent-encoded string
+ * @return the decoded string
+ */
+ private static String decodeResourceAttributes(String value) {
+ // no percent signs means nothing to decode
+ if (value.indexOf('%') < 0) {
+ return value;
+ }
+
+ int n = value.length();
+ // Use byte array to properly handle multi-byte UTF-8 sequences
+ byte[] bytes = new byte[n];
+ int pos = 0;
+
+ for (int i = 0; i < n; i++) {
+ char c = value.charAt(i);
+ // Check for percent-encoded sequence i.e. '%' followed by two hex digits
+ if (c == '%' && i + 2 < n) {
+ int d1 = Character.digit(value.charAt(i + 1), 16);
+ int d2 = Character.digit(value.charAt(i + 2), 16);
+ // Valid hex digits return 0-15, invalid returns -1
+ if (d1 != -1 && d2 != -1) {
+ // Combine two hex digits into a single byte (e.g., "2F" becomes 0x2F)
+ bytes[pos++] = (byte) ((d1 << 4) + d2);
+ // Skip the two hex digits (loop will also do i++)
+ i += 2;
+ continue;
+ }
+ }
+ // Keep '+' as '+' (unlike URLDecoder) and preserve invalid percent sequences
+ // which will be
+ // treated as literals
+ bytes[pos++] = (byte) c;
+ }
+ return new String(bytes, 0, pos, StandardCharsets.UTF_8);
+ }
+
+ private static final class Segment {
+ private final String source;
+ private int start;
+ private int end;
+ private boolean needsDecoding;
+
+ Segment(String source) {
+ this.source = source;
+ reset(0);
+ }
+
+ void reset(int start) {
+ this.start = start;
+ this.end = start;
+ this.needsDecoding = false;
+ }
+
+ void markEnd(int end) {
+ this.end = end;
+ }
+
+ void markNeedsDecoding() {
+ this.needsDecoding = true;
+ }
+
+ boolean isEmpty() {
+ return start >= end;
+ }
+
+ String getValue() {
+ if (isEmpty()) {
+ return "";
+ }
+ String substring = source.substring(start, end).trim();
+ return needsDecoding ? decodeResourceAttributes(substring) : substring;
+ }
+ }
+
+ // State machine parser
+ private static final class EntityParser {
+ private static final Logger logger = Logger.getLogger(EntityParser.class.getName());
+
+ private enum State {
+ TYPE,
+ ID_KEY,
+ ID_VAL,
+ DESC_KEY,
+ DESC_VAL,
+ SCHEMA_URL,
+ SKIP_TO_NEXT
+ }
+
+ private final String input;
+ private State state = State.TYPE;
+ private final Segment currentSegment;
+ private final List entities = new ArrayList<>();
+
+ @Nullable private String currentType;
+ private Attributes currentIdAttrs = Attributes.empty();
+ private Attributes currentDescAttrs = Attributes.empty();
+ @Nullable private String currentSchemaUrl;
+ @Nullable private AttributesBuilder currentBuilder;
+ @Nullable private String currentKey;
+
+ EntityParser(String input) {
+ this.input = input;
+ this.currentSegment = new Segment(input);
+ }
+
+ List parse() {
+ int n = input.length();
+ for (int i = 0; i < n; i++) {
+ char c = input.charAt(i);
+
+ if (state == State.SKIP_TO_NEXT) {
+ if (c == ';') {
+ resetEntityState(i + 1);
+ state = State.TYPE;
+ }
+ continue;
+ }
+
+ switch (c) {
+ case '{':
+ if (state == State.TYPE) {
+ currentSegment.markEnd(i);
+ currentType = currentSegment.getValue();
+ if (currentType == null || currentType.isEmpty()) {
+ logger.log(Level.WARNING, "Malformed entity definition (empty type): " + input);
+ state = State.SKIP_TO_NEXT;
+ } else {
+ state = State.ID_KEY;
+ currentSegment.reset(i + 1);
+ currentBuilder = Attributes.builder();
+ }
+ }
+ break;
+ case '}':
+ if (state == State.ID_VAL || state == State.ID_KEY) {
+ currentSegment.markEnd(i);
+ if (state == State.ID_VAL) {
+ putAttr();
+ }
+ if (currentBuilder != null) {
+ currentIdAttrs = currentBuilder.build();
+ }
+ if (currentIdAttrs.isEmpty()) {
+ logger.log(
+ Level.WARNING,
+ "Malformed entity definition (missing identifying attributes): " + input);
+ state = State.SKIP_TO_NEXT;
+ } else {
+ state = State.TYPE;
+ currentSegment.reset(i + 1);
+ }
+ }
+ break;
+ case '[':
+ if (state == State.TYPE) {
+ state = State.DESC_KEY;
+ currentSegment.reset(i + 1);
+ currentBuilder = Attributes.builder();
+ }
+ break;
+ case ']':
+ if (state == State.DESC_VAL || state == State.DESC_KEY) {
+ currentSegment.markEnd(i);
+ if (state == State.DESC_VAL) {
+ putAttr();
+ }
+ if (currentBuilder != null) {
+ currentDescAttrs = currentBuilder.build();
+ }
+ state = State.TYPE;
+ currentSegment.reset(i + 1);
+ }
+ break;
+ case '=':
+ if (state == State.ID_KEY || state == State.DESC_KEY) {
+ currentSegment.markEnd(i);
+ currentKey = currentSegment.getValue();
+ if (currentKey == null || currentKey.isEmpty()) {
+ logger.log(Level.WARNING, "Malformed key-value pair (empty key): " + input);
+ state = State.SKIP_TO_NEXT;
+ } else {
+ state = (state == State.ID_KEY) ? State.ID_VAL : State.DESC_VAL;
+ currentSegment.reset(i + 1);
+ }
+ }
+ break;
+ case ',':
+ if (state == State.ID_VAL || state == State.DESC_VAL) {
+ currentSegment.markEnd(i);
+ putAttr();
+ state = (state == State.ID_VAL) ? State.ID_KEY : State.DESC_KEY;
+ currentSegment.reset(i + 1);
+ }
+ break;
+ case '@':
+ if (state == State.TYPE) {
+ state = State.SCHEMA_URL;
+ currentSegment.reset(i + 1);
+ }
+ break;
+ case ';':
+ if (state == State.TYPE || state == State.SCHEMA_URL) {
+ if (state == State.SCHEMA_URL) {
+ currentSegment.markEnd(i);
+ currentSchemaUrl = currentSegment.getValue();
+ }
+ buildAndAddEntity();
+ resetEntityState(i + 1);
+ state = State.TYPE;
+ } else if (state == State.ID_KEY
+ || state == State.ID_VAL
+ || state == State.DESC_KEY
+ || state == State.DESC_VAL) {
+ logger.log(Level.WARNING, "Malformed entity definition (unexpected ';'): " + input);
+ resetEntityState(i + 1);
+ state = State.TYPE;
+ }
+ break;
+ case '%':
+ currentSegment.markNeedsDecoding();
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (state == State.TYPE || state == State.SCHEMA_URL) {
+ if (state == State.SCHEMA_URL) {
+ currentSegment.markEnd(input.length());
+ currentSchemaUrl = currentSegment.getValue();
+ }
+ buildAndAddEntity();
+ }
+
+ return entities;
+ }
+
+ private void putAttr() {
+ String val = currentSegment.getValue();
+ if (currentKey != null && !currentKey.isEmpty() && currentBuilder != null) {
+ currentBuilder.put(currentKey, val);
+ }
+ }
+
+ private void buildAndAddEntity() {
+ if (currentType != null && !currentType.isEmpty() && !currentIdAttrs.isEmpty()) {
+ EntityBuilder builder = Entity.builder(currentType).setId(currentIdAttrs);
+ if (!currentDescAttrs.isEmpty()) {
+ builder.setDescription(currentDescAttrs);
+ }
+ if (currentSchemaUrl != null && !currentSchemaUrl.isEmpty()) {
+ builder.setSchemaUrl(currentSchemaUrl);
+ }
+ entities.add(builder.build());
+ }
+ }
+
+ private void resetEntityState(int nextStart) {
+ currentType = null;
+ currentIdAttrs = Attributes.empty();
+ currentDescAttrs = Attributes.empty();
+ currentSchemaUrl = null;
+ currentBuilder = null;
+ currentKey = null;
+ currentSegment.reset(nextStart);
+ }
+ }
+}
diff --git a/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider b/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider
index 189af738dcf..a39b8f1eecf 100644
--- a/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider
+++ b/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider
@@ -1 +1,2 @@
io.opentelemetry.sdk.extension.incubator.resources.ServiceInstanceIdResourceProvider
+io.opentelemetry.sdk.extension.incubator.resources.EnvResourceProvider
diff --git a/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider b/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider
new file mode 100644
index 00000000000..7c5a45f4dae
--- /dev/null
+++ b/sdk-extensions/incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider
@@ -0,0 +1 @@
+io.opentelemetry.sdk.extension.incubator.resources.EnvResourceProvider
diff --git a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProviderTest.java b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProviderTest.java
new file mode 100644
index 00000000000..dabcb73d9d2
--- /dev/null
+++ b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProviderTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.extension.incubator.resources;
+
+import static io.opentelemetry.api.common.AttributeKey.stringKey;
+import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.resources.internal.Entity;
+import io.opentelemetry.sdk.resources.internal.EntityUtil;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class EnvResourceProviderTest {
+
+ @Test
+ void getTypeAndName() {
+ EnvResourceProvider provider = new EnvResourceProvider();
+ assertThat(provider.getType()).isEqualTo(Resource.class);
+ assertThat(provider.getName()).isEqualTo("env");
+ assertThat(provider.order()).isEqualTo(0);
+ }
+
+ @Test
+ void createResource_EmptyOrNull() {
+ EnvResourceProvider provider = new EnvResourceProvider();
+
+ Resource emptyResource =
+ provider.createResource(DefaultConfigProperties.createFromMap(Collections.emptyMap()));
+ assertThat(EntityUtil.getEntities(emptyResource)).isEmpty();
+
+ Resource blankResource =
+ provider.createResource(
+ DefaultConfigProperties.createFromMap(Collections.singletonMap("otel.entities", "")));
+ assertThat(EntityUtil.getEntities(blankResource)).isEmpty();
+ }
+
+ @Test
+ void createResource_WithEntities() {
+ Map props = new HashMap<>();
+ props.put(
+ "otel.entities",
+ "process{process.pid=1234}[process.executable.name=java]@http://schema;host{host.id=myhost}");
+
+ EnvResourceProvider provider = new EnvResourceProvider();
+ Resource resource = provider.createResource(DefaultConfigProperties.createFromMap(props));
+
+ Collection entities = EntityUtil.getEntities(resource);
+ assertThat(entities).hasSize(2);
+
+ assertThat(entities)
+ .anyMatch(
+ e ->
+ e.getType().equals("process")
+ && e.getSchemaUrl().equals("http://schema")
+ && e.getId().equals(Attributes.of(stringKey("process.pid"), "1234"))
+ && e.getDescription()
+ .equals(Attributes.of(stringKey("process.executable.name"), "java")));
+
+ assertThat(entities)
+ .anyMatch(
+ e ->
+ e.getType().equals("host")
+ && e.getSchemaUrl() == null
+ && e.getId().equals(Attributes.of(stringKey("host.id"), "myhost")));
+ }
+
+ @Test
+ void createResource_PercentDecoding() {
+ Map props = new HashMap<>();
+ props.put(
+ "otel.entities",
+ "service{service.name=my+app,space=hello%20world,utf8=%C3%A9,invalid=%2G,incomplete=%2,end=%}");
+
+ EnvResourceProvider provider = new EnvResourceProvider();
+ Resource resource = provider.createResource(DefaultConfigProperties.createFromMap(props));
+
+ Collection entities = EntityUtil.getEntities(resource);
+ assertThat(entities).hasSize(1);
+
+ Entity entity = entities.iterator().next();
+ assertThat(entity.getId())
+ .containsEntry(stringKey("service.name"), "my+app")
+ .containsEntry(stringKey("space"), "hello world")
+ .containsEntry(stringKey("utf8"), "é")
+ .containsEntry(stringKey("invalid"), "%2G")
+ .containsEntry(stringKey("incomplete"), "%2")
+ .containsEntry(stringKey("end"), "%");
+ }
+
+ @Test
+ void createResource_Malformed() {
+ Map props = new HashMap<>();
+ props.put(
+ "otel.entities",
+ "{empty.type=val};process{};process{=val};process{key;=val};host{host.id=valid}");
+
+ EnvResourceProvider provider = new EnvResourceProvider();
+ Resource resource = provider.createResource(DefaultConfigProperties.createFromMap(props));
+
+ Collection entities = EntityUtil.getEntities(resource);
+ // Only the last valid host entity should be parsed
+ assertThat(entities).hasSize(1);
+ assertThat(entities.iterator().next().getType()).isEqualTo("host");
+ }
+
+ @Test
+ void create_ComponentProvider() {
+ System.setProperty("otel.entities", "service{service.name=my-service}");
+ try {
+ EnvResourceProvider provider = new EnvResourceProvider();
+ Resource resource = provider.create(DeclarativeConfigProperties.empty());
+ Collection entities = EntityUtil.getEntities(resource);
+ assertThat(entities).hasSize(1);
+ assertThat(entities)
+ .anyMatch(
+ e ->
+ e.getType().equals("service")
+ && e.getId().equals(Attributes.of(stringKey("service.name"), "my-service")));
+ } finally {
+ System.clearProperty("otel.entities");
+ }
+ }
+}
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/Resource.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/Resource.java
index 25003b6ba73..d1f6ad15441 100644
--- a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/Resource.java
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/Resource.java
@@ -9,11 +9,13 @@
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
-import io.opentelemetry.api.internal.StringUtils;
-import io.opentelemetry.api.internal.Utils;
import io.opentelemetry.sdk.common.internal.OtelVersion;
+import io.opentelemetry.sdk.resources.internal.AttributeCheckUtil;
+import io.opentelemetry.sdk.resources.internal.Entity;
+import io.opentelemetry.sdk.resources.internal.EntityUtil;
+import java.util.Collection;
+import java.util.Collections;
import java.util.Objects;
-import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
@@ -24,8 +26,6 @@
@Immutable
@AutoValue
public abstract class Resource {
- private static final Logger logger = Logger.getLogger(Resource.class.getName());
-
private static final AttributeKey SERVICE_NAME = AttributeKey.stringKey("service.name");
private static final AttributeKey TELEMETRY_SDK_LANGUAGE =
AttributeKey.stringKey("telemetry.sdk.language");
@@ -33,14 +33,6 @@ public abstract class Resource {
AttributeKey.stringKey("telemetry.sdk.name");
private static final AttributeKey TELEMETRY_SDK_VERSION =
AttributeKey.stringKey("telemetry.sdk.version");
-
- private static final int MAX_LENGTH = 255;
- private static final String ERROR_MESSAGE_INVALID_CHARS =
- " should be a ASCII string with a length greater than 0 and not exceed "
- + MAX_LENGTH
- + " characters.";
- private static final String ERROR_MESSAGE_INVALID_VALUE =
- " should be a ASCII string with a length not exceed " + MAX_LENGTH + " characters.";
private static final Resource EMPTY = create(Attributes.empty());
private static final Resource TELEMETRY_SDK;
@@ -91,7 +83,7 @@ public static Resource empty() {
* @return a {@code Resource}.
* @throws NullPointerException if {@code attributes} is null.
* @throws IllegalArgumentException if attribute key or attribute value is not a valid printable
- * ASCII string or exceed {@link #MAX_LENGTH} characters.
+ * ASCII string or exceed {@link AttributeCheckUtil#MAX_LENGTH} characters.
*/
public static Resource create(Attributes attributes) {
return create(attributes, null);
@@ -105,11 +97,36 @@ public static Resource create(Attributes attributes) {
* @return a {@code Resource}.
* @throws NullPointerException if {@code attributes} is null.
* @throws IllegalArgumentException if attribute key or attribute value is not a valid printable
- * ASCII string or exceed {@link #MAX_LENGTH} characters.
+ * ASCII string or exceed {@link AttributeCheckUtil#MAX_LENGTH} characters.
*/
public static Resource create(Attributes attributes, @Nullable String schemaUrl) {
- checkAttributes(Objects.requireNonNull(attributes, "attributes"));
- return new AutoValue_Resource(schemaUrl, attributes);
+ return create(attributes, schemaUrl, Collections.emptyList());
+ }
+
+ /**
+ * Returns a {@link Resource}.
+ *
+ * @param attributes a map of {@link Attributes} that describe the resource.
+ * @param schemaUrl The URL of the OpenTelemetry schema used to create this Resource.
+ * @param entities The set of detected {@link Entity}s that participate in this resource.
+ * @return a {@code Resource}.
+ * @throws NullPointerException if {@code attributes} is null.
+ * @throws IllegalArgumentException if attribute key or attribute value is not a valid printable
+ * ASCII string or exceed {@link AttributeCheckUtil#MAX_LENGTH} characters.
+ */
+ static Resource create(
+ Attributes attributes, @Nullable String schemaUrl, Collection entities) {
+ AttributeCheckUtil.checkAttributes(Objects.requireNonNull(attributes, "attributes"));
+ // Memoize the full set of attributes
+ AttributesBuilder fullAttributes = Attributes.builder();
+ entities.forEach(
+ e -> {
+ fullAttributes.putAll(e.getId());
+ fullAttributes.putAll(e.getDescription());
+ });
+ // In merge rules, raw comes last, so we return these last.
+ fullAttributes.putAll(attributes);
+ return new AutoValue_Resource(schemaUrl, entities, fullAttributes.build());
}
/**
@@ -121,6 +138,30 @@ public static Resource create(Attributes attributes, @Nullable String schemaUrl)
@Nullable
public abstract String getSchemaUrl();
+ /**
+ * Returns a map of attributes that describe the resource, not associated with entities.
+ *
+ * @return a map of attributes.
+ */
+ final Attributes getRawAttributes() {
+ AttributesBuilder rawAttributes = getAttributes().toBuilder();
+ rawAttributes.removeIf(
+ key ->
+ getEntities().stream()
+ .anyMatch(
+ entity ->
+ entity.getId().get(key) != null
+ || entity.getDescription().get(key) != null));
+ return rawAttributes.build();
+ }
+
+ /**
+ * Returns a collection of associated entities.
+ *
+ * @return a collection of entities.
+ */
+ abstract Collection getEntities();
+
/**
* Returns a map of attributes that describe the resource.
*
@@ -146,63 +187,7 @@ public T getAttribute(AttributeKey key) {
* @return the newly merged {@code Resource}.
*/
public Resource merge(@Nullable Resource other) {
- if (other == null || other.equals(EMPTY)) {
- return this;
- }
-
- AttributesBuilder attrBuilder = Attributes.builder();
- attrBuilder.putAll(this.getAttributes());
- attrBuilder.putAll(other.getAttributes());
-
- if (other.getSchemaUrl() == null) {
- return create(attrBuilder.build(), getSchemaUrl());
- }
- if (getSchemaUrl() == null) {
- return create(attrBuilder.build(), other.getSchemaUrl());
- }
- if (!other.getSchemaUrl().equals(getSchemaUrl())) {
- logger.info(
- "Attempting to merge Resources with different schemaUrls. "
- + "The resulting Resource will have no schemaUrl assigned. Schema 1: "
- + getSchemaUrl()
- + " Schema 2: "
- + other.getSchemaUrl());
- // currently, behavior is undefined if schema URLs don't match. In the future, we may
- // apply schema transformations if possible.
- return create(attrBuilder.build(), null);
- }
- return create(attrBuilder.build(), getSchemaUrl());
- }
-
- private static void checkAttributes(Attributes attributes) {
- attributes.forEach(
- (key, value) -> {
- Utils.checkArgument(
- isValidAndNotEmpty(key), "Attribute key" + ERROR_MESSAGE_INVALID_CHARS);
- Objects.requireNonNull(value, "Attribute value" + ERROR_MESSAGE_INVALID_VALUE);
- });
- }
-
- /**
- * Determines whether the given {@code String} is a valid printable ASCII string with a length not
- * exceed {@link #MAX_LENGTH} characters.
- *
- * @param name the name to be validated.
- * @return whether the name is valid.
- */
- private static boolean isValid(String name) {
- return name.length() <= MAX_LENGTH && StringUtils.isPrintableString(name);
- }
-
- /**
- * Determines whether the given {@code String} is a valid printable ASCII string with a length
- * greater than 0 and not exceed {@link #MAX_LENGTH} characters.
- *
- * @param name the name to be validated.
- * @return whether the name is valid.
- */
- private static boolean isValidAndNotEmpty(AttributeKey> name) {
- return !name.getKey().isEmpty() && isValid(name.getKey());
+ return EntityUtil.merge(this, other);
}
/**
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/ResourceBuilder.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/ResourceBuilder.java
index b01437bd6fc..5d54944390d 100644
--- a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/ResourceBuilder.java
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/ResourceBuilder.java
@@ -8,7 +8,13 @@
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.sdk.resources.internal.Entity;
+import io.opentelemetry.sdk.resources.internal.EntityUtil;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
import java.util.function.Predicate;
+import java.util.stream.Collectors;
import javax.annotation.Nullable;
/**
@@ -20,6 +26,7 @@
public class ResourceBuilder {
private final AttributesBuilder attributesBuilder = Attributes.builder();
+ private final List entities = new ArrayList<>();
@Nullable private String schemaUrl;
/**
@@ -169,7 +176,11 @@ public ResourceBuilder putAll(Attributes attributes) {
/** Puts all attributes from {@link Resource} into this. */
public ResourceBuilder putAll(Resource resource) {
if (resource != null) {
- attributesBuilder.putAll(resource.getAttributes());
+ // Preserve entities when merging resources.
+ entities.addAll(resource.getEntities());
+ // Only pull "raw" attributes - we expect entities to carry some of the full
+ // set.
+ attributesBuilder.putAll(resource.getRawAttributes());
}
return this;
}
@@ -194,6 +205,24 @@ public ResourceBuilder setSchemaUrl(String schemaUrl) {
/** Create the {@link Resource} from this. */
public Resource build() {
- return Resource.create(attributesBuilder.build(), schemaUrl);
+ // Derive schemaUrl from entity, if able.
+ if (schemaUrl == null) {
+ Set entitySchemas =
+ entities.stream().map(Entity::getSchemaUrl).collect(Collectors.toSet());
+ if (entitySchemas.size() == 1) {
+ // Updated Entities use same schema, we can preserve it.
+ schemaUrl = entitySchemas.iterator().next();
+ }
+ }
+
+ // When adding an entity, we remove any raw attributes it may conflict with.
+ this.attributesBuilder.removeIf(key -> EntityUtil.hasAttributeKey(this.entities, key));
+ return Resource.create(attributesBuilder.build(), schemaUrl, entities);
+ }
+
+ /** Appends a new entity on to the end of the list of entities. */
+ ResourceBuilder addEntity(Entity e) {
+ this.entities.add(e);
+ return this;
}
}
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/AttributeCheckUtil.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/AttributeCheckUtil.java
new file mode 100644
index 00000000000..966c5b58277
--- /dev/null
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/AttributeCheckUtil.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.resources.internal;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.internal.StringUtils;
+import io.opentelemetry.api.internal.Utils;
+import java.util.Objects;
+
+/**
+ * Helpers to check resource attributes.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public final class AttributeCheckUtil {
+ private AttributeCheckUtil() {}
+
+ // Note: Max length is actually configurable by specification.
+ private static final int MAX_LENGTH = 255;
+ private static final String ERROR_MESSAGE_INVALID_CHARS =
+ " should be a ASCII string with a length greater than 0 and not exceed "
+ + MAX_LENGTH
+ + " characters.";
+ private static final String ERROR_MESSAGE_INVALID_VALUE =
+ " should be a ASCII string with a length not exceed " + MAX_LENGTH + " characters.";
+
+ /** Determine if the set of attributes if valid for Resource / Entity. */
+ public static void checkAttributes(Attributes attributes) {
+ attributes.forEach(
+ (key, value) -> {
+ Utils.checkArgument(
+ isValidAndNotEmpty(key), "Attribute key" + ERROR_MESSAGE_INVALID_CHARS);
+ Objects.requireNonNull(value, "Attribute value" + ERROR_MESSAGE_INVALID_VALUE);
+ });
+ }
+
+ /**
+ * Determines whether the given {@code String} is a valid printable ASCII string with a length
+ * greater than 0 and not exceed {@link #MAX_LENGTH} characters.
+ *
+ * @param name the name to be validated.
+ * @return whether the name is valid.
+ */
+ public static boolean isValidAndNotEmpty(AttributeKey> name) {
+ return !name.getKey().isEmpty() && isValid(name.getKey());
+ }
+
+ /**
+ * Determines whether the given {@code String} is a valid printable ASCII string with a length not
+ * exceed {@link #MAX_LENGTH} characters.
+ *
+ * @param name the name to be validated.
+ * @return whether the name is valid.
+ */
+ public static boolean isValid(String name) {
+ return name.length() <= MAX_LENGTH && StringUtils.isPrintableString(name);
+ }
+}
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/Entity.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/Entity.java
new file mode 100644
index 00000000000..2e647e4fc0e
--- /dev/null
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/Entity.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.resources.internal;
+
+import io.opentelemetry.api.common.Attributes;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * Entity represents an object of interest associated with produced telemetry: traces, metrics or
+ * logs.
+ *
+ *
For example, telemetry produced using OpenTelemetry SDK is normally associated with a Service
+ * entity. Similarly, OpenTelemetry defines system metrics for a host. The Host is the entity we
+ * want to associate metrics with in this case.
+ *
+ *
Entities may be also associated with produced telemetry indirectly. For example a service that
+ * produces telemetry is also related with a process in which the service runs, so we say that the
+ * Service entity is related to the Process entity. The process normally also runs on a host, so we
+ * say that the Process entity is related to the Host entity.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+@Immutable
+public interface Entity {
+ /**
+ * Returns the entity type string of this entity. Must not be null.
+ *
+ * @return the entity type.
+ */
+ String getType();
+
+ /**
+ * Returns a map of attributes that identify the entity.
+ *
+ * @return the entity identity.
+ */
+ Attributes getId();
+
+ /**
+ * Returns a map of attributes that describe the entity.
+ *
+ * @return the entity description.
+ */
+ Attributes getDescription();
+
+ /**
+ * Returns the URL of the OpenTelemetry schema used by this resource. May be null if this entity
+ * does not abide by schema conventions (i.e. is custom).
+ *
+ * @return An OpenTelemetry schema URL.
+ */
+ @Nullable
+ String getSchemaUrl();
+
+ /**
+ * Returns a new {@link EntityBuilder} instance populated with the data of this {@link Entity}.
+ */
+ EntityBuilder toBuilder();
+
+ /**
+ * Returns a new {@link EntityBuilder} instance for creating arbitrary {@link Entity}.
+ *
+ * @param entityType the entity type string of this entity.
+ */
+ static EntityBuilder builder(String entityType) {
+ return new SdkEntityBuilder(entityType);
+ }
+}
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityBuilder.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityBuilder.java
new file mode 100644
index 00000000000..52a910aec9d
--- /dev/null
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityBuilder.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.resources.internal;
+
+import io.opentelemetry.api.common.Attributes;
+
+/**
+ * A builder of {@link Entity} that allows to add identifying or descriptive {@link Attributes}, as
+ * well as type and schema_url.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public interface EntityBuilder {
+ /**
+ * Assign an OpenTelemetry schema URL to the resulting Entity.
+ *
+ * @param schemaUrl The URL of the OpenTelemetry schema being used to create this Entity.
+ * @return this
+ */
+ EntityBuilder setSchemaUrl(String schemaUrl);
+
+ /**
+ * Modify the descriptive attributes of this Entity.
+ *
+ * @param description The attributes that describe the Entity.
+ * @return this
+ */
+ EntityBuilder setDescription(Attributes description);
+
+ /**
+ * Modify the identifying attributes of this Entity.
+ *
+ * @param id The identifying attributes.
+ * @return this
+ */
+ EntityBuilder setId(Attributes id);
+
+ /** Create the {@link Entity} from this. */
+ Entity build();
+}
diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityUtil.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityUtil.java
new file mode 100644
index 00000000000..e6f16934031
--- /dev/null
+++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/EntityUtil.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.resources.internal;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.resources.ResourceBuilder;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/**
+ * Helper class for dealing with Entities.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public final class EntityUtil {
+ private static final Logger logger = Logger.getLogger(EntityUtil.class.getName());
+
+ private EntityUtil() {}
+
+ /**
+ * Constructs a new {@link Resource} with Entity support.
+ *
+ * @param entities The set of entities the resource needs.
+ * @return A constructed resource.
+ */
+ public static Resource createResource(Collection entities) {
+ return createResourceRaw(
+ Attributes.empty(), EntityUtil.mergeResourceSchemaUrl(entities, null, null), entities);
+ }
+
+ /**
+ * Constructs a new {@link Resource} with Entity support.
+ *
+ * @param attributes The raw attributes for the resource.
+ * @param schemaUrl The schema url for the resource.
+ * @param entities The set of entities the resource needs.
+ * @return A constructed resource.
+ */
+ static Resource createResourceRaw(
+ Attributes attributes, @Nullable String schemaUrl, Collection entities) {
+ try {
+ Method method =
+ Resource.class.getDeclaredMethod(
+ "create", Attributes.class, String.class, Collection.class);
+ if (method != null) {
+ method.setAccessible(true);
+ Object result = method.invoke(null, attributes, schemaUrl, entities);
+ if (result instanceof Resource) {
+ return (Resource) result;
+ }
+ }
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+ logger.log(Level.WARNING, "Attempting to use entities with unsupported resource", e);
+ }
+ // Fall back to non-entity behavior?
+ logger.log(Level.WARNING, "Attempting to use entities with unsupported resource");
+ return Resource.empty();
+ }
+
+ /** Appends a new entity on to the end of the list of entities. */
+ public static ResourceBuilder addEntity(ResourceBuilder rb, Entity e) {
+ try {
+ Method method = ResourceBuilder.class.getDeclaredMethod("addEntity", Entity.class);
+ if (method != null) {
+ method.setAccessible(true);
+ method.invoke(rb, e);
+ }
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
+ logger.log(Level.WARNING, "Attempting to use entities with unsupported resource", ex);
+ }
+ return rb;
+ }
+
+ /**
+ * Returns a collectoion of associated entities.
+ *
+ * @return a collection of entities.
+ */
+ @SuppressWarnings("unchecked")
+ public static Collection getEntities(Resource r) {
+ try {
+ Method method = Resource.class.getDeclaredMethod("getEntities");
+ if (method != null) {
+ method.setAccessible(true);
+ return (Collection) method.invoke(r);
+ }
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+ logger.log(Level.WARNING, "Attempting to use entities with unsupported resource", e);
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Returns a map of attributes that describe the resource, not associated with entites.
+ *
+ * @return a map of attributes.
+ */
+ public static Attributes getRawAttributes(Resource r) {
+ try {
+ Method method = Resource.class.getDeclaredMethod("getRawAttributes");
+ if (method != null) {
+ method.setAccessible(true);
+ return (Attributes) method.invoke(r);
+ }
+ } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+ logger.log(Level.WARNING, "Attempting to use entities with unsupported resource", e);
+ }
+ return Attributes.empty();
+ }
+
+ /** Returns true if any entity in the collection has the attribute key, in id or description. */
+ public static boolean hasAttributeKey(Collection entities, AttributeKey key) {
+ return entities.stream()
+ .anyMatch(
+ e -> e.getId().asMap().containsKey(key) || e.getDescription().asMap().containsKey(key));
+ }
+
+ /** Decides on a final SchemaURL for OTLP Resource based on entities chosen. */
+ @Nullable
+ static String mergeResourceSchemaUrl(
+ Collection entities, @Nullable String baseUrl, @Nullable String nextUrl) {
+ // Check if entities all share the same URL.
+ Set entitySchemas =
+ entities.stream().map(Entity::getSchemaUrl).collect(Collectors.toSet());
+ // If we have no entities, we preserve previous schema url behavior.
+ String result = baseUrl;
+ if (entitySchemas.size() == 1) {
+ // Updated Entities use same schema, we can preserve it.
+ result = entitySchemas.iterator().next();
+ } else if (entitySchemas.size() > 1) {
+ // Entities use different schemas, resource must treat this as no schema_url.
+ result = null;
+ }
+
+ // If schema url of merging resource is null, we use our current result.
+ if (nextUrl == null) {
+ return result;
+ }
+ // When there are no entities, we use old schema url merge behavior
+ if (result == null && entities.isEmpty()) {
+ return nextUrl;
+ }
+ if (!nextUrl.equals(result)) {
+ logger.info(
+ "Attempting to merge Resources with different schemaUrls. "
+ + "The resulting Resource will have no schemaUrl assigned. Schema 1: "
+ + baseUrl
+ + " Schema 2: "
+ + nextUrl);
+ return null;
+ }
+ return result;
+ }
+
+ /**
+ * Merges "loose" attributes on resource, removing those which conflict with the set of entities.
+ *
+ * @param base loose attributes from base resource
+ * @param additional additional attributes to add to the resource.
+ * @param entities the set of entites on the resource.
+ * @return the new set of raw attributes for Resource and the set of conflicting entities that
+ * MUST NOT be reported on OTLP resource.
+ */
+ @SuppressWarnings("unchecked")
+ static final RawAttributeMergeResult mergeRawAttributes(
+ Attributes base, Attributes additional, Collection entities) {
+ AttributesBuilder result = base.toBuilder();
+ // We know attribute conflicts were handled perviously on the resource, so
+ // This needs to account for entity merge of new entities, and remove raw
+ // attributes that would have been removed with new entities.
+ result.removeIf(key -> hasAttributeKey(entities, key));
+ // For every "raw" attribute on the other resource, we merge into the
+ // resource, but check for entity conflicts from previous entities.
+ ArrayList conflicts = new ArrayList<>();
+ if (!additional.isEmpty()) {
+ additional.forEach(
+ (key, value) -> {
+ for (Entity e : entities) {
+ if (e.getId().get(key) != null || e.getDescription().get(key) != null) {
+ // Remove the entity and push all attributes as raw,
+ // we have an override.
+ conflicts.add(e);
+ result.putAll(e.getId()).putAll(e.getDescription());
+ }
+ }
+ result.put((AttributeKey