diff --git a/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshaler.java b/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshaler.java new file mode 100644 index 00000000000..b464c810a59 --- /dev/null +++ b/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshaler.java @@ -0,0 +1,83 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.otlp; + +import io.opentelemetry.api.internal.StringUtils; +import io.opentelemetry.exporter.internal.marshal.MarshalerUtil; +import io.opentelemetry.exporter.internal.marshal.MarshalerWithSize; +import io.opentelemetry.exporter.internal.marshal.Serializer; +import io.opentelemetry.proto.common.v1.internal.EntityRef; +import io.opentelemetry.sdk.resources.internal.Entity; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; + +/** + * A Marshaler of {@link io.opentelemetry.sdk.resources.internal.Entity}. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +final class EntityRefMarshaler extends MarshalerWithSize { + @Nullable private final byte[] schemaUrlUtf8; + private final byte[] typeUtf8; + private final byte[][] idKeysUtf8; + private final byte[][] descriptionKeysUtf8; + + @Override + protected void writeTo(Serializer output) throws IOException { + if (schemaUrlUtf8 != null) { + output.writeString(EntityRef.SCHEMA_URL, schemaUrlUtf8); + } + output.writeString(EntityRef.TYPE, typeUtf8); + output.writeRepeatedString(EntityRef.ID_KEYS, idKeysUtf8); + output.writeRepeatedString(EntityRef.DESCRIPTION_KEYS, descriptionKeysUtf8); + } + + /** Consttructs an entity reference marshaler from a full entity. */ + static EntityRefMarshaler createForEntity(Entity e) { + byte[] schemaUrlUtf8 = null; + if (!StringUtils.isNullOrEmpty(e.getSchemaUrl())) { + schemaUrlUtf8 = e.getSchemaUrl().getBytes(StandardCharsets.UTF_8); + } + return new EntityRefMarshaler( + schemaUrlUtf8, + e.getType().getBytes(StandardCharsets.UTF_8), + e.getId().asMap().keySet().stream() + .map(key -> key.getKey().getBytes(StandardCharsets.UTF_8)) + .toArray(byte[][]::new), + e.getDescription().asMap().keySet().stream() + .map(key -> key.getKey().getBytes(StandardCharsets.UTF_8)) + .toArray(byte[][]::new)); + } + + private EntityRefMarshaler( + @Nullable byte[] schemaUrlUtf8, + byte[] typeUtf8, + byte[][] idKeysUtf8, + byte[][] descriptionKeysUtf8) { + super(calculateSize(schemaUrlUtf8, typeUtf8, idKeysUtf8, descriptionKeysUtf8)); + this.schemaUrlUtf8 = schemaUrlUtf8; + this.typeUtf8 = typeUtf8; + this.idKeysUtf8 = idKeysUtf8; + this.descriptionKeysUtf8 = descriptionKeysUtf8; + } + + private static int calculateSize( + @Nullable byte[] schemaUrlUtf8, + byte[] typeUtf8, + byte[][] idKeysUtf8, + byte[][] descriptionKeysUtf8) { + int size = 0; + if (schemaUrlUtf8 != null) { + size += MarshalerUtil.sizeBytes(EntityRef.SCHEMA_URL, schemaUrlUtf8); + } + size += MarshalerUtil.sizeBytes(EntityRef.TYPE, typeUtf8); + size += MarshalerUtil.sizeRepeatedString(EntityRef.ID_KEYS, idKeysUtf8); + size += MarshalerUtil.sizeRepeatedString(EntityRef.DESCRIPTION_KEYS, descriptionKeysUtf8); + return size; + } +} diff --git a/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshaler.java b/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshaler.java index b3395448a79..b7bc0cad505 100644 --- a/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshaler.java +++ b/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshaler.java @@ -10,6 +10,7 @@ import io.opentelemetry.exporter.internal.marshal.MarshalerWithSize; import io.opentelemetry.exporter.internal.marshal.Serializer; import io.opentelemetry.proto.resource.v1.internal.Resource; +import io.opentelemetry.sdk.resources.internal.EntityUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; @@ -37,7 +38,10 @@ public static ResourceMarshaler create(io.opentelemetry.sdk.resources.Resource r RealResourceMarshaler realMarshaler = new RealResourceMarshaler( - KeyValueMarshaler.createForAttributes(resource.getAttributes())); + KeyValueMarshaler.createForAttributes(resource.getAttributes()), + EntityUtil.getEntities(resource).stream() + .map(EntityRefMarshaler::createForEntity) + .toArray(MarshalerWithSize[]::new)); ByteArrayOutputStream binaryBos = new ByteArrayOutputStream(realMarshaler.getBinarySerializedSize()); @@ -70,19 +74,26 @@ public void writeTo(Serializer output) throws IOException { private static final class RealResourceMarshaler extends MarshalerWithSize { private final KeyValueMarshaler[] attributes; + private final MarshalerWithSize[] entityRefs; - private RealResourceMarshaler(KeyValueMarshaler[] attributes) { - super(calculateSize(attributes)); + private RealResourceMarshaler(KeyValueMarshaler[] attributes, MarshalerWithSize[] entityRefs) { + super(calculateSize(attributes, entityRefs)); this.attributes = attributes; + this.entityRefs = entityRefs; } @Override protected void writeTo(Serializer output) throws IOException { output.serializeRepeatedMessage(Resource.ATTRIBUTES, attributes); + output.serializeRepeatedMessage(Resource.ENTITY_REFS, entityRefs); } - private static int calculateSize(KeyValueMarshaler[] attributeMarshalers) { - return MarshalerUtil.sizeRepeatedMessage(Resource.ATTRIBUTES, attributeMarshalers); + private static int calculateSize( + KeyValueMarshaler[] attributeMarshalers, MarshalerWithSize[] entityRefs) { + int size = 0; + size += MarshalerUtil.sizeRepeatedMessage(Resource.ATTRIBUTES, attributeMarshalers); + size += MarshalerUtil.sizeRepeatedMessage(Resource.ENTITY_REFS, entityRefs); + return size; } } } diff --git a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshalerTest.java b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshalerTest.java new file mode 100644 index 00000000000..f2f7af4fc08 --- /dev/null +++ b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/EntityRefMarshalerTest.java @@ -0,0 +1,87 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.otlp; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.internal.marshal.Marshaler; +import io.opentelemetry.proto.common.v1.EntityRef; +import io.opentelemetry.sdk.resources.internal.Entity; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class EntityRefMarshalerTest { + @Test + void toEntityRefs() { + Entity e = + Entity.builder("test") + .setSchemaUrl("test-url") + .setDescription(Attributes.builder().put("desc.key", "desc.value").build()) + .setId(Attributes.builder().put("id.key", "id.value").build()) + .build(); + EntityRef proto = parse(EntityRef.getDefaultInstance(), EntityRefMarshaler.createForEntity(e)); + assertThat(proto.getType()).isEqualTo("test"); + assertThat(proto.getSchemaUrl()).isEqualTo("test-url"); + assertThat(proto.getIdKeysList()).containsExactly("id.key"); + assertThat(proto.getDescriptionKeysList()).containsExactly("desc.key"); + } + + @SuppressWarnings("unchecked") + private static T parse(T prototype, Marshaler marshaler) { + byte[] serialized = toByteArray(marshaler); + T result; + try { + result = (T) prototype.newBuilderForType().mergeFrom(serialized).build(); + } catch (InvalidProtocolBufferException e) { + throw new UncheckedIOException(e); + } + // Our marshaler should produce the exact same length of serialized output (for example, field + // default values are not outputted), so we check that here. The output itself may have slightly + // different ordering, mostly due to the way we don't output oneof values in field order all the + // tieme. If the lengths are equal and the resulting protos are equal, the marshaling is + // guaranteed to be valid. + assertThat(result.getSerializedSize()).isEqualTo(serialized.length); + + // Compare JSON + String json = toJson(marshaler); + Message.Builder builder = prototype.newBuilderForType(); + try { + JsonFormat.parser().merge(json, builder); + } catch (InvalidProtocolBufferException e) { + throw new UncheckedIOException(e); + } + assertThat(builder.build()).isEqualTo(result); + + return result; + } + + private static byte[] toByteArray(Marshaler marshaler) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + marshaler.writeBinaryTo(bos); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return bos.toByteArray(); + } + + private static String toJson(Marshaler marshaler) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + marshaler.writeJsonTo(bos); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshalerTest.java b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshalerTest.java new file mode 100644 index 00000000000..f75aed04781 --- /dev/null +++ b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/ResourceMarshalerTest.java @@ -0,0 +1,100 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.otlp; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.internal.marshal.Marshaler; +import io.opentelemetry.proto.resource.v1.Resource; +import io.opentelemetry.sdk.resources.ResourceBuilder; +import io.opentelemetry.sdk.resources.internal.Entity; +import io.opentelemetry.sdk.resources.internal.EntityUtil; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ResourceMarshalerTest { + + @Test + void marshalResourceWithEntities() { + Entity entity = + Entity.builder("process") + .setId(Attributes.of(stringKey("process.pid"), "1234")) + .setDescription(Attributes.of(stringKey("process.executable.name"), "java")) + .setSchemaUrl("http://process.schema") + .build(); + + ResourceBuilder builder = + io.opentelemetry.sdk.resources.Resource.builder().put("service.name", "my-service"); + EntityUtil.addEntity(builder, entity); + io.opentelemetry.sdk.resources.Resource resourceWithEntity = builder.build(); + + Resource proto = + parse(Resource.getDefaultInstance(), ResourceMarshaler.create(resourceWithEntity)); + + assertThat(proto.getAttributesList()).hasSize(3); + assertThat(proto.getAttributesList().stream().map(a -> a.getKey())) + .containsExactlyInAnyOrder("service.name", "process.pid", "process.executable.name"); + + assertThat(proto.getEntityRefsList()).hasSize(1); + assertThat(proto.getEntityRefs(0).getType()).isEqualTo("process"); + assertThat(proto.getEntityRefs(0).getSchemaUrl()).isEqualTo("http://process.schema"); + assertThat(proto.getEntityRefs(0).getIdKeysList()).containsExactly("process.pid"); + assertThat(proto.getEntityRefs(0).getDescriptionKeysList()) + .containsExactly("process.executable.name"); + } + + @SuppressWarnings("unchecked") + private static T parse(T prototype, Marshaler marshaler) { + byte[] serialized = toByteArray(marshaler); + T result; + try { + result = (T) prototype.newBuilderForType().mergeFrom(serialized).build(); + } catch (InvalidProtocolBufferException e) { + throw new UncheckedIOException(e); + } + assertThat(result.getSerializedSize()).isEqualTo(serialized.length); + + // Compare JSON + String json = toJson(marshaler); + Message.Builder protoBuilder = prototype.newBuilderForType(); + try { + JsonFormat.parser().merge(json, protoBuilder); + } catch (InvalidProtocolBufferException e) { + throw new UncheckedIOException(e); + } + assertThat(protoBuilder.build()).isEqualTo(result); + + return result; + } + + private static byte[] toByteArray(Marshaler marshaler) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + marshaler.writeBinaryTo(bos); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return bos.toByteArray(); + } + + private static String toJson(Marshaler marshaler) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + marshaler.writeJsonTo(bos); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/EntityExperimentConstants.java b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/EntityExperimentConstants.java new file mode 100644 index 00000000000..4083fb5409d --- /dev/null +++ b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/EntityExperimentConstants.java @@ -0,0 +1,20 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.autoconfigure.spi.internal; + +/** + * Constants for experimental entity SDK features. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class EntityExperimentConstants { + + /** The configuration key for enabling experimental entity support in resource detectors. */ + public static final String EXPERIMENTAL_ENTITIES_ENABLED = "otel.experimental.entities.enabled"; + + private EntityExperimentConstants() {} +} diff --git a/sdk-extensions/autoconfigure/build.gradle.kts b/sdk-extensions/autoconfigure/build.gradle.kts index 288fff4be41..7f596a40fca 100644 --- a/sdk-extensions/autoconfigure/build.gradle.kts +++ b/sdk-extensions/autoconfigure/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { api(project(":sdk-extensions:autoconfigure-spi")) compileOnly(project(":api:incubator")) + compileOnly(project(":sdk-extensions:incubator")) compileOnly(project(":sdk-extensions:declarative-config")) annotationProcessor("com.google.auto.value:auto-value") @@ -107,6 +108,7 @@ testing { register("testIncubating") { dependencies { implementation(project(":sdk-extensions:declarative-config")) + implementation(project(":sdk-extensions:incubator")) implementation(project(":exporters:logging")) implementation(project(":exporters:otlp:all")) implementation(project(":sdk:testing")) diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetector.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetector.java index f09d44a6275..0496a687dc9 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetector.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetector.java @@ -6,12 +6,16 @@ package io.opentelemetry.sdk.autoconfigure.declarativeconfig; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider; import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.EntityExperimentConstants; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.resources.ResourceBuilder; +import io.opentelemetry.sdk.resources.internal.Entity; +import io.opentelemetry.sdk.resources.internal.EntityUtil; import java.util.Collections; import java.util.UUID; @@ -21,6 +25,10 @@ public class ServiceResourceDetector implements ComponentProvider { private static final AttributeKey SERVICE_INSTANCE_ID = AttributeKey.stringKey("service.instance.id"); + private static final String SCHEMA_URL = "https://opentelemetry.io/schemas/1.40.0"; + private static final String SERVICE_TYPE = "service"; + private static final String SERVICE_INSTANCE_TYPE = "service.instance"; + // multiple calls to this resource provider should return the same value private static final String RANDOM_SERVICE_INSTANCE_ID = UUID.randomUUID().toString(); @@ -40,13 +48,32 @@ public Resource create(DeclarativeConfigProperties config) { ConfigProperties properties = DefaultConfigProperties.create(Collections.emptyMap(), config.getComponentLoader()); + boolean entitiesEnabled = + properties.getBoolean(EntityExperimentConstants.EXPERIMENTAL_ENTITIES_ENABLED, false); + String serviceName = properties.getString("otel.service.name"); - if (serviceName != null) { - builder.put(SERVICE_NAME, serviceName).build(); + if (entitiesEnabled) { + if (serviceName != null) { + Entity serviceEntity = + Entity.builder(SERVICE_TYPE) + .setId(Attributes.of(SERVICE_NAME, serviceName)) + .setSchemaUrl(SCHEMA_URL) + .build(); + EntityUtil.addEntity(builder, serviceEntity); + } + Entity serviceInstanceEntity = + Entity.builder(SERVICE_INSTANCE_TYPE) + .setId(Attributes.of(SERVICE_INSTANCE_ID, RANDOM_SERVICE_INSTANCE_ID)) + .setSchemaUrl(SCHEMA_URL) + .build(); + EntityUtil.addEntity(builder, serviceInstanceEntity); + } else { + if (serviceName != null) { + builder.put(SERVICE_NAME, serviceName); + } + builder.put(SERVICE_INSTANCE_ID, RANDOM_SERVICE_INSTANCE_ID); } - builder.put(SERVICE_INSTANCE_ID, RANDOM_SERVICE_INSTANCE_ID); - return builder.build(); } } diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java index 89674fd798d..69b3ff4eb0e 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java @@ -183,15 +183,13 @@ void create_ModelCustomizer() { ComponentLoader.forClassLoader( DeclarativeConfigurationCreateTest.class.getClassLoader())) .getSdk(); - assertThat(sdk.toString()) - .contains( - "resource=Resource{schemaUrl=null, attributes={" - + "color=\"blue\", " - + "foo=\"bar\", " - + "service.name=\"unknown_service:java\", " - + "telemetry.sdk.language=\"java\", " - + "telemetry.sdk.name=\"opentelemetry\", " - + "telemetry.sdk.version=\""); + String sdkStr = sdk.toString(); + assertThat(sdkStr).contains("color=\"blue\""); + assertThat(sdkStr).contains("foo=\"bar\""); + assertThat(sdkStr).contains("service.name=\"unknown_service:java\""); + assertThat(sdkStr).contains("telemetry.sdk.language=\"java\""); + assertThat(sdkStr).contains("telemetry.sdk.name=\"opentelemetry\""); + assertThat(sdkStr).contains("telemetry.sdk.version=\""); } @Test diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetectorTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetectorTest.java index 8513423a0a9..cb8ce64e184 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetectorTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ServiceResourceDetectorTest.java @@ -12,10 +12,12 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; 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.Objects; import java.util.UUID; import org.junit.jupiter.api.Test; -import org.junitpioneer.jupiter.ClearSystemProperty; class ServiceResourceDetectorTest { @@ -28,26 +30,30 @@ void getTypeAndName() { } @Test - @ClearSystemProperty(key = "otel.service.name") void create_SystemPropertySet() { System.setProperty("otel.service.name", "test"); - - assertThat(new ServiceResourceDetector().create(DeclarativeConfigProperties.empty())) - .satisfies( - resource -> { - Attributes attributes = resource.getAttributes(); - assertThat(attributes.get(AttributeKey.stringKey("service.name"))).isEqualTo("test"); - assertThatCode( - () -> - UUID.fromString( - Objects.requireNonNull( - attributes.get(AttributeKey.stringKey("service.instance.id"))))) - .doesNotThrowAnyException(); - }); + try { + assertThat(new ServiceResourceDetector().create(DeclarativeConfigProperties.empty())) + .satisfies( + resource -> { + Attributes attributes = resource.getAttributes(); + assertThat(attributes.get(AttributeKey.stringKey("service.name"))) + .isEqualTo("test"); + assertThatCode( + () -> + UUID.fromString( + Objects.requireNonNull( + attributes.get(AttributeKey.stringKey("service.instance.id"))))) + .doesNotThrowAnyException(); + }); + } finally { + System.clearProperty("otel.service.name"); + } } @Test void create_NoSystemProperty() { + System.clearProperty("otel.service.name"); assertThat(new ServiceResourceDetector().create(DeclarativeConfigProperties.empty())) .satisfies( resource -> { @@ -61,4 +67,59 @@ void create_NoSystemProperty() { .doesNotThrowAnyException(); }); } + + @Test + void create_EntitiesEnabled() { + System.setProperty("otel.service.name", "my-service"); + System.setProperty("otel.experimental.entities.enabled", "true"); + try { + Resource resource = new ServiceResourceDetector().create(DeclarativeConfigProperties.empty()); + + Collection entities = EntityUtil.getEntities(resource); + assertThat(entities).hasSize(2); + + assertThat(entities) + .anyMatch( + e -> + e.getType().equals("service") + && e.getSchemaUrl().equals("https://opentelemetry.io/schemas/1.40.0") + && e.getId() + .equals( + Attributes.of(AttributeKey.stringKey("service.name"), "my-service"))); + + assertThat(entities) + .anyMatch( + e -> + e.getType().equals("service.instance") + && e.getSchemaUrl().equals("https://opentelemetry.io/schemas/1.40.0") + && e.getId().get(AttributeKey.stringKey("service.instance.id")) != null); + + // Flat attributes should also be present + Attributes attributes = resource.getAttributes(); + assertThat(attributes.get(AttributeKey.stringKey("service.name"))).isEqualTo("my-service"); + assertThat(attributes.get(AttributeKey.stringKey("service.instance.id"))).isNotNull(); + } finally { + System.clearProperty("otel.service.name"); + System.clearProperty("otel.experimental.entities.enabled"); + } + } + + @Test + void create_EntitiesDisabled() { + System.setProperty("otel.service.name", "my-service"); + System.setProperty("otel.experimental.entities.enabled", "false"); + try { + Resource resource = new ServiceResourceDetector().create(DeclarativeConfigProperties.empty()); + + Collection entities = EntityUtil.getEntities(resource); + assertThat(entities).isEmpty(); + + Attributes attributes = resource.getAttributes(); + assertThat(attributes.get(AttributeKey.stringKey("service.name"))).isEqualTo("my-service"); + assertThat(attributes.get(AttributeKey.stringKey("service.instance.id"))).isNotNull(); + } finally { + System.clearProperty("otel.service.name"); + System.clearProperty("otel.experimental.entities.enabled"); + } + } } diff --git a/sdk-extensions/incubator/build.gradle.kts b/sdk-extensions/incubator/build.gradle.kts index af02be590af..1ede46662f2 100644 --- a/sdk-extensions/incubator/build.gradle.kts +++ b/sdk-extensions/incubator/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { compileOnly(project(":sdk-extensions:autoconfigure-spi")) testImplementation(project(":sdk:testing")) + testImplementation(project(":api:incubator")) + testImplementation(project(":sdk-extensions:autoconfigure-spi")) testImplementation(project(":sdk-extensions:autoconfigure")) testImplementation("edu.berkeley.cs.jqf:jqf-fuzz") testImplementation("com.google.guava:guava-testlib") diff --git a/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProvider.java b/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProvider.java new file mode 100644 index 00000000000..9a4f9174f45 --- /dev/null +++ b/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/resources/EnvResourceProvider.java @@ -0,0 +1,356 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.extension.incubator.resources; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider; +import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.resources.ResourceBuilder; +import io.opentelemetry.sdk.resources.internal.Entity; +import io.opentelemetry.sdk.resources.internal.EntityBuilder; +import io.opentelemetry.sdk.resources.internal.EntityUtil; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * {@link ResourceProvider} and {@link ComponentProvider} for detecting resources via the {@code + * OTEL_ENTITIES} environment variable. + */ +public final class EnvResourceProvider implements ResourceProvider, ComponentProvider { + + private static final String ENTITIES_PROPERTY = "otel.entities"; + + @Override + public Resource createResource(ConfigProperties config) { + return createResourceInternal(config); + } + + @Override + public int order() { + return 0; + } + + @Override + public Class getType() { + return Resource.class; + } + + @Override + public String getName() { + return "env"; + } + + @Override + public Resource create(DeclarativeConfigProperties config) { + ConfigProperties properties = + DefaultConfigProperties.create(Collections.emptyMap(), config.getComponentLoader()); + return createResourceInternal(properties); + } + + private static Resource createResourceInternal(ConfigProperties config) { + String entitiesStr = config.getString(ENTITIES_PROPERTY); + if (entitiesStr == null || entitiesStr.isEmpty()) { + return Resource.empty(); + } + + ResourceBuilder builder = Resource.builder(); + List parsedEntities = new EntityParser(entitiesStr).parse(); + for (Entity entity : parsedEntities) { + EntityUtil.addEntity(builder, entity); + } + return builder.build(); + } + + /** + * Decodes percent-encoded characters in resource attribute values per W3C Baggage spec. + * + *

Unlike {@link java.net.URLDecoder}, this method: + * + *

+ * + * @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) key, value); + }); + } + return RawAttributeMergeResult.create(result.build(), conflicts); + } + + /** + * Merges entities according to specification rules. + * + * @param base the initial set of entities. + * @param additional Additional entities to merge with base set. + * @return A new set of entities with no duplicate types. + */ + static Collection mergeEntities(Collection base, Collection additional) { + if (base.isEmpty()) { + return additional; + } + if (additional.isEmpty()) { + return base; + } + Map entities = new HashMap<>(); + base.forEach(e -> entities.put(e.getType(), e)); + for (Entity e : additional) { + if (!entities.containsKey(e.getType())) { + entities.put(e.getType(), e); + } else { + Entity old = entities.get(e.getType()); + // If the entity identity is the same, but schema_url is different: drop the new entity d' + // Note: We could offer configuration in this case + if (old.getSchemaUrl() == null || !old.getSchemaUrl().equals(e.getSchemaUrl())) { + logger.info( + "Discovered conflicting entities. Entity [" + + old.getType() + + "] has different schema url [" + + old.getSchemaUrl() + + "], new entity with schema url[" + + e.getSchemaUrl() + + "] is dropped."); + } else if (!old.getId().equals(e.getId())) { + // If the entity identity is different: drop the new entity d'. + logger.info( + "Discovered conflicting entities. Entity [" + + old.getType() + + "] has identity [" + + old.getId() + + "], new entity [" + + e.getId() + + "] is dropped."); + } else { + // If the entity identity and schema_url are the same, merge the descriptive attributes + // of d' into e': + // For each descriptive attribute da' in d' + // If da'.key does not exist in e', then add da' to ei + // otherwise, ignore. + Entity next = + old.toBuilder() + .setDescription( + Attributes.builder() + .putAll(e.getDescription()) + .putAll(old.getDescription()) + .build()) + .build(); + entities.put(next.getType(), next); + } + } + } + return entities.values(); + } + + /** + * Returns a new, merged {@link Resource} by merging the {@code base} {@code Resource} with the + * {@code next} {@code Resource}. In case of a collision, the "next" {@code Resource} takes + * precedence. + * + * @param base the {@code Resource} into which we merge new values. + * @param next the {@code Resource} that will be merged with {@code base}. + * @return the newly merged {@code Resource}. + */ + public static Resource merge(Resource base, @Nullable Resource next) { + if (next == null || next.equals(Resource.empty())) { + return base; + } + // Merge Algorithm from + // https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/entities/0264-resource-and-entities.md#entity-merging-and-resource + Collection entities = EntityUtil.mergeEntities(getEntities(base), getEntities(next)); + RawAttributeMergeResult attributeResult = + EntityUtil.mergeRawAttributes(getRawAttributes(base), getRawAttributes(next), entities); + // Remove entities that are conflicting with raw attributes, and therefore in an unknown state. + entities.removeAll(attributeResult.getConflicts()); + // Now figure out schema url for overall resource. + String schemaUrl = + EntityUtil.mergeResourceSchemaUrl(entities, base.getSchemaUrl(), next.getSchemaUrl()); + return createResourceRaw(attributeResult.getAttributes(), schemaUrl, entities); + } +} diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/RawAttributeMergeResult.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/RawAttributeMergeResult.java new file mode 100644 index 00000000000..f1cc081b035 --- /dev/null +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/RawAttributeMergeResult.java @@ -0,0 +1,32 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.resources.internal; + +import com.google.auto.value.AutoValue; +import io.opentelemetry.api.common.Attributes; +import java.util.Collection; +import javax.annotation.concurrent.Immutable; + +/** + * This class is internal and is hence not for public use. Its APIs are unstable and can change at + * any time. + */ +@Immutable +@AutoValue +abstract class RawAttributeMergeResult { + /** Merged raw attributes. */ + abstract Attributes getAttributes(); + + /** + * Entities in conflict that should be removed from resource to avoid reporting invalid attribute + * sets in OTLP resource. + */ + abstract Collection getConflicts(); + + static final RawAttributeMergeResult create(Attributes attributes, Collection conflicts) { + return new AutoValue_RawAttributeMergeResult(attributes, conflicts); + } +} diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntity.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntity.java new file mode 100644 index 00000000000..4aa65d2942b --- /dev/null +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntity.java @@ -0,0 +1,39 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.resources.internal; + +import com.google.auto.value.AutoValue; +import io.opentelemetry.api.common.Attributes; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * SDK implementation of Entity. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +@Immutable +@AutoValue +abstract class SdkEntity implements Entity { + /** + * Returns a {@link Entity}. + * + * @param entityType the entity type string of this entity. + * @param id a map of attributes that identify the entity. + * @param description a map of attributes that describe the entity. + * @return a {@code Entity}. + */ + static Entity create( + String entityType, Attributes id, Attributes description, @Nullable String schemaUrl) { + return new AutoValue_SdkEntity(entityType, id, description, schemaUrl); + } + + @Override + public final EntityBuilder toBuilder() { + return new SdkEntityBuilder(this); + } +} diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntityBuilder.java b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntityBuilder.java new file mode 100644 index 00000000000..f4f62024f12 --- /dev/null +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/resources/internal/SdkEntityBuilder.java @@ -0,0 +1,62 @@ +/* + * 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; + +/** + * 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. + */ +final class SdkEntityBuilder implements EntityBuilder { + private final String entityType; + private Attributes description; + private Attributes id; + @Nullable private String schemaUrl; + + SdkEntityBuilder(String entityType) { + AttributeCheckUtil.isValid(entityType); + this.entityType = entityType; + this.description = Attributes.empty(); + this.id = Attributes.empty(); + } + + SdkEntityBuilder(Entity seed) { + this.entityType = seed.getType(); + this.schemaUrl = seed.getSchemaUrl(); + this.id = seed.getId(); + this.description = seed.getDescription(); + } + + @Override + public EntityBuilder setSchemaUrl(String schemaUrl) { + this.schemaUrl = schemaUrl; + return this; + } + + @Override + public EntityBuilder setDescription(Attributes description) { + AttributeCheckUtil.checkAttributes(description); + this.description = description; + return this; + } + + @Override + public EntityBuilder setId(Attributes id) { + AttributeCheckUtil.checkAttributes(id); + this.id = id; + return this; + } + + @Override + public Entity build() { + return SdkEntity.create(entityType, id, description, schemaUrl); + } +} diff --git a/sdk/common/src/test/java/io/opentelemetry/sdk/resources/ResourceTest.java b/sdk/common/src/test/java/io/opentelemetry/sdk/resources/ResourceTest.java index 62a2e2219f4..f171c5d9ff7 100644 --- a/sdk/common/src/test/java/io/opentelemetry/sdk/resources/ResourceTest.java +++ b/sdk/common/src/test/java/io/opentelemetry/sdk/resources/ResourceTest.java @@ -23,6 +23,8 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.Value; +import io.opentelemetry.sdk.resources.internal.Entity; +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.BeforeEach; @@ -201,6 +203,14 @@ void testResourceEquals() { .testEquals(); } + @Test + void testToString() { + Attributes attribute1 = Attributes.of(stringKey("a"), "1", stringKey("b"), "2"); + Resource resource = Resource.create(attribute1, "http://schema"); + assertThat(resource.toString()) + .isEqualTo("Resource{schemaUrl=http://schema, entities=[], attributes={a=\"1\", b=\"2\"}}"); + } + @Test void testMergeResources() { Attributes expectedAttributes = @@ -227,6 +237,31 @@ void testMergeResources_schema() { assertThat(schemaTwo.merge(schemaOne).getSchemaUrl()).isNull(); } + @Test + void testMergeResources_entities_separate_types_and_schema() { + Resource resource1 = + Resource.builder() + .addEntity( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .build()) + .build(); + Resource resource2 = + Resource.builder() + .addEntity( + Entity.builder("b") + .setSchemaUrl("two") + .setId(Attributes.builder().put("b.id", "b").build()) + .build()) + .build(); + Resource merged = resource1.merge(resource2); + assertThat(merged.getSchemaUrl()).isNull(); + assertThat(merged.getEntities()).hasSize(2); + OpenTelemetryAssertions.assertThat(merged.getAttributes()).containsEntry("a.id", "a"); + OpenTelemetryAssertions.assertThat(merged.getAttributes()).containsEntry("b.id", "b"); + } + @Test void testMergeResources_Resource1() { Attributes expectedAttributes = Attributes.of(stringKey("a"), "1", stringKey("b"), "2"); diff --git a/sdk/common/src/test/java/io/opentelemetry/sdk/resources/internal/EntityUtilTest.java b/sdk/common/src/test/java/io/opentelemetry/sdk/resources/internal/EntityUtilTest.java new file mode 100644 index 00000000000..332dd198401 --- /dev/null +++ b/sdk/common/src/test/java/io/opentelemetry/sdk/resources/internal/EntityUtilTest.java @@ -0,0 +1,238 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.resources.internal; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link EntityUtil}. */ +class EntityUtilTest { + @Test + void testMerge_entities_same_types_and_id() { + Collection base = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .setDescription(Attributes.builder().put("a.desc1", "a").build()) + .build()); + Collection added = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .setDescription(Attributes.builder().put("a.desc2", "b").build()) + .build()); + Collection merged = EntityUtil.mergeEntities(base, added); + assertThat(merged).hasSize(1); + assertThat(merged) + .anySatisfy( + entity -> { + assertThat(entity.getType()).isEqualTo("a"); + assertThat(entity.getSchemaUrl()).isEqualTo("one"); + assertThat(entity.getId()).containsEntry("a.id", "a"); + assertThat(entity.getDescription()) + .containsEntry("a.desc1", "a") + .containsEntry("a.desc2", "b"); + }); + } + + @Test + void testMerge_entities_same_types_and_id_different_schema() { + Collection base = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .setDescription(Attributes.builder().put("a.desc1", "a").build()) + .build()); + Collection added = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("two") + .setId(Attributes.builder().put("a.id", "a").build()) + .setDescription(Attributes.builder().put("a.desc2", "b").build()) + .build()); + Collection merged = EntityUtil.mergeEntities(base, added); + assertThat(merged).hasSize(1); + assertThat(merged) + .anySatisfy( + entity -> { + assertThat(entity.getType()).isEqualTo("a"); + assertThat(entity.getSchemaUrl()).isEqualTo("one"); + assertThat(entity.getId()).containsEntry("a.id", "a"); + assertThat(entity.getDescription()) + .containsEntry("a.desc1", "a") + // Don't merge between versions. + .doesNotContainKey("a.desc2"); + }); + } + + @Test + void testMerge_entities_same_types_different_id() { + Collection base = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .setDescription(Attributes.builder().put("a.desc1", "a").build()) + .build()); + Collection added = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "b").build()) + .setDescription(Attributes.builder().put("a.desc2", "b").build()) + .build()); + Collection merged = EntityUtil.mergeEntities(base, added); + assertThat(merged).hasSize(1); + assertThat(merged) + .satisfiesExactly( + entity -> { + assertThat(entity.getType()).isEqualTo("a"); + assertThat(entity.getSchemaUrl()).isEqualTo("one"); + assertThat(entity.getId()).containsEntry("a.id", "a"); + assertThat(entity.getDescription()) + .containsEntry("a.desc1", "a") + // Don't merge between different ids. + .doesNotContainKey("a.desc2"); + }); + } + + @Test + void testMerge_entities_separate_types_and_schema() { + Collection base = + Arrays.asList( + Entity.builder("a") + .setSchemaUrl("one") + .setId(Attributes.builder().put("a.id", "a").build()) + .build()); + Collection added = + Arrays.asList( + Entity.builder("b") + .setSchemaUrl("two") + .setId(Attributes.builder().put("b.id", "b").build()) + .build()); + Collection merged = EntityUtil.mergeEntities(base, added); + // Make sure we keep both entities when no conflict. + assertThat(merged) + .satisfiesExactlyInAnyOrder( + a -> assertThat(a.getType()).isEqualTo("a"), + b -> assertThat(b.getType()).isEqualTo("b")); + } + + @Test + void testSchemaUrlMerge_no_entities_differentUrls() { + // If the we find conflicting schema URLs in resource we must drop schema url (set to null). + String result = EntityUtil.mergeResourceSchemaUrl(Collections.emptyList(), "one", "two"); + assertThat(result).isNull(); + } + + @Test + void testSchemaUrlMerge_no_entities_base_null() { + // If the our resource had no schema url it abides by, we use the incoming schema url. + String result = EntityUtil.mergeResourceSchemaUrl(Collections.emptyList(), null, "two"); + assertThat(result).isEqualTo("two"); + } + + @Test + void testSchemaUrlMerge_no_entities_next_null() { + // If the new resource had no schema url it abides by, we preserve ours. + // NOTE: this is by specification, but seems problematic if conflicts in merge + // cause violation of SchemaURL. + String result = EntityUtil.mergeResourceSchemaUrl(Collections.emptyList(), "one", null); + assertThat(result).isEqualTo("one"); + } + + @Test + void testSchemaUrlMerge_entities_same_url() { + // If the new resource had no schema url it abides by, we preserve ours. + // NOTE: this is by specification, but seems problematic if conflicts in merge + // cause violation of SchemaURL. + String result = + EntityUtil.mergeResourceSchemaUrl( + Arrays.asList( + Entity.builder("t") + .setSchemaUrl("one") + .setId(Attributes.builder().put("id", 1).build()) + .build()), + "one", + null); + assertThat(result).isEqualTo("one"); + } + + @Test + void testSchemaUrlMerge_entities_different_url() { + // When entities have conflicting schema urls, we cannot fill out resource schema url, + // no matter what. + String result = + EntityUtil.mergeResourceSchemaUrl( + Arrays.asList( + Entity.builder("t") + .setSchemaUrl("one") + .setId(Attributes.builder().put("id", 1).build()) + .build(), + Entity.builder("t2") + .setSchemaUrl("two") + .setId(Attributes.builder().put("id2", 1).build()) + .build()), + "one", + "one"); + assertThat(result).isEqualTo(null); + } + + @Test + void testRawAttributeMerge_no_entities() { + // When no entities are present all attributes are merged. + RawAttributeMergeResult result = + EntityUtil.mergeRawAttributes( + Attributes.builder().put("a", 1).put("b", 1).build(), + Attributes.builder().put("b", 2).put("c", 2).build(), + Collections.emptyList()); + assertThat(result.getConflicts()).isEmpty(); + assertThat(result.getAttributes()) + .hasSize(3) + .containsEntry("a", 1) + .containsEntry("b", 2) + .containsEntry("c", 2); + } + + @Test + void testRawAttributeMerge_entity_with_conflict() { + // When an entity conflicts with incoming raw attributes, we need to call out that conflict + // so resource merge logic can remove the entity from resource. + RawAttributeMergeResult result = + EntityUtil.mergeRawAttributes( + Attributes.builder().put("a", 1).put("b", 1).build(), + Attributes.builder().put("b", 2).put("c", 2).build(), + Arrays.asList( + Entity.builder("c").setId(Attributes.builder().put("c", 1).build()).build())); + assertThat(result.getConflicts()).satisfiesExactly(e -> assertThat(e.getType()).isEqualTo("c")); + assertThat(result.getAttributes()) + .hasSize(3) + .containsEntry("a", 1) + .containsEntry("b", 2) + .containsEntry("c", 2); + } + + @Test + void testAddEntity_reflection() { + Resource result = + EntityUtil.addEntity( + Resource.builder(), + Entity.builder("a").setId(Attributes.builder().put("a", 1).build()).build()) + .build(); + assertThat(EntityUtil.getEntities(result)) + .satisfiesExactlyInAnyOrder(e -> assertThat(e.getType()).isEqualTo("a")); + } +} diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLoggerProviderTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLoggerProviderTest.java index aa58b8869c3..fe46e763fab 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLoggerProviderTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLoggerProviderTest.java @@ -344,14 +344,12 @@ void close() { void toString_Valid() { when(logRecordProcessor.toString()).thenReturn("MockLogRecordProcessor"); assertThat(sdkLoggerProvider.toString()) - .isEqualTo( - "SdkLoggerProvider{" - + "clock=SystemClock{}, " - + "resource=Resource{schemaUrl=null, attributes={key=\"value\"}}, " - + "logLimits=LogLimits{maxNumberOfAttributes=128, maxAttributeValueLength=2147483647}, " + .matches( + "SdkLoggerProvider\\{clock=SystemClock\\{\\}, " + + "resource=Resource\\{schemaUrl=null, entities=\\[\\], attributes=\\{key=\"value\"\\}\\}, " + + "logLimits=LogLimits\\{maxNumberOfAttributes=128, maxAttributeValueLength=2147483647\\}, " + "logRecordProcessor=MockLogRecordProcessor, " - + "loggerConfigurator=ScopeConfiguratorImpl{conditions=[]}" - + "}"); + + "loggerConfigurator=ScopeConfiguratorImpl\\{conditions=\\[\\]\\}\\}"); } private static ScopeConfigurator flipConfigurator(boolean enabled) { 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 83697d45e37..d9371c1c827 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 @@ -1109,9 +1109,10 @@ void spanDataToString() { + "traceFlags=00, " + "traceState=ArrayBasedTraceState\\{entries=\\[]}, remote=false, valid=false}, " + "resource=Resource\\{schemaUrl=null, " + + "entities=\\[\\], " + "attributes=\\{service.name=\"unknown_service:java\", " + "telemetry.sdk.language=\"java\", telemetry.sdk.name=\"opentelemetry\", " - + "telemetry.sdk.version=\"\\d+.\\d+.\\d+(-rc.\\d+)?(-SNAPSHOT)?\"}}, " + + "telemetry.sdk.version=\"\\d+.\\d+.\\d+(-rc.\\d+)?(-SNAPSHOT)?\"\\}\\}, " + "instrumentationScopeInfo=InstrumentationScopeInfo\\{" + "name=SpanBuilderSdkTest, version=null, schemaUrl=null, attributes=\\{}}, " + "name=span_name, "