diff --git a/resource_detectors/BUILD b/resource_detectors/BUILD index f12e695647..d137736b18 100644 --- a/resource_detectors/BUILD +++ b/resource_detectors/BUILD @@ -16,6 +16,7 @@ cc_library( srcs = [ "container_detector.cc", "container_detector_utils.cc", + "env_entity_detector.cc", "process_detector.cc", "process_detector_utils.cc", ], diff --git a/resource_detectors/CMakeLists.txt b/resource_detectors/CMakeLists.txt index 7fdba7d73b..233984aa1e 100644 --- a/resource_detectors/CMakeLists.txt +++ b/resource_detectors/CMakeLists.txt @@ -3,8 +3,8 @@ add_library( opentelemetry_resource_detectors - container_detector_utils.cc container_detector.cc process_detector.cc - process_detector_utils.cc) + container_detector_utils.cc container_detector.cc env_entity_detector.cc + process_detector.cc process_detector_utils.cc) set_target_properties(opentelemetry_resource_detectors PROPERTIES EXPORT_NAME resource_detectors) diff --git a/resource_detectors/env_entity_detector.cc b/resource_detectors/env_entity_detector.cc new file mode 100644 index 0000000000..6334cf08b0 --- /dev/null +++ b/resource_detectors/env_entity_detector.cc @@ -0,0 +1,374 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "opentelemetry/common/string_util.h" +#include "opentelemetry/nostd/string_view.h" +#include "opentelemetry/nostd/variant.h" +#include "opentelemetry/resource_detectors/env_entity_detector.h" +#include "opentelemetry/sdk/common/attribute_utils.h" +#include "opentelemetry/sdk/common/env_variables.h" +#include "opentelemetry/sdk/common/global_log_handler.h" +#include "opentelemetry/sdk/resource/resource.h" +#include "opentelemetry/sdk/resource/resource_detector.h" +#include "opentelemetry/version.h" + +OPENTELEMETRY_BEGIN_NAMESPACE +namespace resource_detector +{ + +namespace +{ + +constexpr const char *kOtelEntities = "OTEL_ENTITIES"; + +struct ParsedEntity +{ + std::string type; + opentelemetry::sdk::resource::ResourceAttributes id_attrs; + opentelemetry::sdk::resource::ResourceAttributes desc_attrs; + std::string schema_url; + std::string identity_key; // Pre-computed identity key for duplicate detection +}; + +std::string BuildEntityIdentityKey(const std::string &type, + const opentelemetry::sdk::resource::ResourceAttributes &id_attrs) +{ + using AttrPtr = + const std::pair *; + std::vector items; + items.reserve(id_attrs.size()); + for (const auto &kv : id_attrs) + { + items.push_back(&kv); + } + std::sort(items.begin(), items.end(), [](AttrPtr a, AttrPtr b) { return a->first < b->first; }); + + std::string key = type + "|"; + for (size_t i = 0; i < items.size(); ++i) + { + if (i > 0) + { + key += ","; + } + key += items[i]->first; + key += "="; + const auto *str_val = opentelemetry::nostd::get_if(&items[i]->second); + if (str_val != nullptr) + { + key += *str_val; + } + } + return key; +} + +std::string PercentDecode(opentelemetry::nostd::string_view value) noexcept +{ + if (value.find('%') == opentelemetry::nostd::string_view::npos) + { + return std::string(value); + } + + std::string result; + result.reserve(value.size()); + + auto IsHex = [](char c) { + return std::isdigit(static_cast(c)) || (c >= 'A' && c <= 'F') || + (c >= 'a' && c <= 'f'); + }; + + auto FromHex = [](char c) -> char { + return static_cast(std::isdigit(static_cast(c)) + ? c - '0' + : std::toupper(static_cast(c)) - 'A' + 10); + }; + + for (size_t i = 0; i < value.size(); ++i) + { + if (value[i] == '%' && i + 2 < value.size() && IsHex(value[i + 1]) && IsHex(value[i + 2])) + { + result.push_back(static_cast((FromHex(value[i + 1]) << 4) | FromHex(value[i + 2]))); + i += 2; + } + else + { + result.push_back(value[i]); + } + } + + return result; +} + +bool IsValidSchemaUrl(const std::string &url) noexcept +{ + if (url.empty()) + { + return false; + } + + // If absolute URI (has ://), validate scheme + size_t scheme_end = url.find("://"); + if (scheme_end != std::string::npos) + { + if (scheme_end == 0 || scheme_end + 3 >= url.size()) + { + return false; // Empty scheme or no content after :// + } + // Scheme must start with letter + if (!std::isalpha(static_cast(url[0]))) + { + return false; + } + // Scheme can contain letters, digits, +, -, . + for (size_t i = 1; i < scheme_end; ++i) + { + char c = url[i]; + if (!(std::isalnum(static_cast(c)) || c == '+' || c == '-' || c == '.')) + { + return false; + } + } + return true; + } + + // Relative URI - accept any non-empty string + return true; +} + +void ParseKeyValueList(const std::string &input, + opentelemetry::sdk::resource::ResourceAttributes &out) +{ + std::istringstream iss(input); + std::string token; + while (std::getline(iss, token, ',')) + { + token = std::string{opentelemetry::common::StringUtil::Trim(token)}; + if (token.empty()) + { + continue; + } + size_t pos = token.find('='); + if (pos == std::string::npos) + { + continue; + } + std::string key = token.substr(0, pos); + std::string value = token.substr(pos + 1); + key = std::string{opentelemetry::common::StringUtil::Trim(key)}; + value = std::string{opentelemetry::common::StringUtil::Trim(value)}; + if (key.empty()) + { + continue; + } + out[key] = PercentDecode(value); + } +} + +bool ParseSingleEntity(const std::string &entity_str, ParsedEntity &out) +{ + if (entity_str.empty()) + { + return false; + } + + // type is everything before first '{' + size_t brace_pos = entity_str.find('{'); + if (brace_pos == std::string::npos || brace_pos == 0) + { + return false; + } + + out.type = std::string{opentelemetry::common::StringUtil::Trim(entity_str.substr(0, brace_pos))}; + + // Validate type matches [a-zA-Z][a-zA-Z0-9._-]* + if (out.type.empty() || !std::isalpha(static_cast(out.type[0]))) + { + return false; + } + for (size_t i = 1; i < out.type.size(); ++i) + { + char c = out.type[i]; + if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) + { + return false; + } + } + + // Extract id_attrs in {...} + size_t id_start = brace_pos + 1; + size_t id_end = entity_str.find('}', id_start); + if (id_end == std::string::npos || id_end <= id_start) + { + return false; + } + std::string id_block = std::string{ + opentelemetry::common::StringUtil::Trim(entity_str.substr(id_start, id_end - id_start))}; + ParseKeyValueList(id_block, out.id_attrs); + if (out.id_attrs.empty()) + { + return false; + } + + // Pre-compute identity key for duplicate detection. + out.identity_key = BuildEntityIdentityKey(out.type, out.id_attrs); + + size_t cursor = id_end + 1; + + // Optional desc_attrs in [...] + if (cursor < entity_str.size() && entity_str[cursor] == '[') + { + size_t desc_start = cursor + 1; + size_t desc_end = entity_str.find(']', desc_start); + if (desc_end == std::string::npos || desc_end <= desc_start) + { + return false; + } + std::string desc_block = std::string{opentelemetry::common::StringUtil::Trim( + entity_str.substr(desc_start, desc_end - desc_start))}; + ParseKeyValueList(desc_block, out.desc_attrs); + cursor = desc_end + 1; + } + + // Optional schema URL: '@...' + if (cursor < entity_str.size() && entity_str[cursor] == '@') + { + out.schema_url = + std::string{opentelemetry::common::StringUtil::Trim(entity_str.substr(cursor + 1))}; + + // TODO: Use a proper Schema URL validator when available. + if (!IsValidSchemaUrl(out.schema_url)) + { + OTEL_INTERNAL_LOG_WARN( + "[EnvEntityDetector] Invalid schema URL in OTEL_ENTITIES, ignoring schema URL."); + out.schema_url.clear(); + } + } + + return true; +} + +std::vector ParseEntities(const std::string &entities_str) +{ + std::vector entities; + + std::istringstream iss(entities_str); + std::string token; + while (std::getline(iss, token, ';')) + { + token = std::string{opentelemetry::common::StringUtil::Trim(token)}; + if (token.empty()) + { + continue; + } + ParsedEntity entity; + if (ParseSingleEntity(token, entity)) + { + entities.push_back(std::move(entity)); + } + else + { + OTEL_INTERNAL_LOG_WARN( + "[EnvEntityDetector] Skipping malformed entity definition in OTEL_ENTITIES."); + } + } + + return entities; +} + +} // namespace + +opentelemetry::sdk::resource::Resource EnvEntityDetector::Detect() noexcept +{ + std::string entities_str; + bool exists = + opentelemetry::sdk::common::GetStringEnvironmentVariable(kOtelEntities, entities_str); + + if (!exists || entities_str.empty()) + { + return opentelemetry::sdk::resource::ResourceDetector::Create({}); + } + + auto parsed_entities = ParseEntities(entities_str); + if (parsed_entities.empty()) + { + return opentelemetry::sdk::resource::ResourceDetector::Create({}); + } + + opentelemetry::sdk::resource::ResourceAttributes resource_attrs; + std::string schema_url; + + std::unordered_map entity_index_by_identity; + entity_index_by_identity.reserve(parsed_entities.size()); + for (size_t i = 0; i < parsed_entities.size(); ++i) + { + const std::string &identity_key = parsed_entities[i].identity_key; + auto it = entity_index_by_identity.find(identity_key); + if (it != entity_index_by_identity.end()) + { + OTEL_INTERNAL_LOG_WARN( + "[EnvEntityDetector] Duplicate entity definition in OTEL_ENTITIES, using last " + "occurrence."); + it->second = i; + continue; + } + entity_index_by_identity.emplace(identity_key, i); + } + + for (size_t i = 0; i < parsed_entities.size(); ++i) + { + const std::string &identity_key = parsed_entities[i].identity_key; + auto it = entity_index_by_identity.find(identity_key); + + // Only process if this is the last occurrence for this identity. + if (it == entity_index_by_identity.end() || it->second != i) + { + continue; + } + + const auto &entity = parsed_entities[i]; + + // Add identifying attributes. + for (const auto &attr : entity.id_attrs) + { + auto existing = resource_attrs.find(attr.first); + if (existing != resource_attrs.end() && existing->second != attr.second) + { + OTEL_INTERNAL_LOG_WARN( + "[EnvEntityDetector] Conflicting identifying attribute in OTEL_ENTITIES, " + "preserving value from last entity."); + } + resource_attrs[attr.first] = attr.second; + } + + // Add descriptive attributes. + for (const auto &attr : entity.desc_attrs) + { + auto existing = resource_attrs.find(attr.first); + if (existing != resource_attrs.end() && existing->second != attr.second) + { + OTEL_INTERNAL_LOG_WARN( + "[EnvEntityDetector] Conflicting descriptive attribute in OTEL_ENTITIES, " + "using value from last entity."); + } + resource_attrs[attr.first] = attr.second; + } + + if (!entity.schema_url.empty()) + { + schema_url = entity.schema_url; + } + } + + return opentelemetry::sdk::resource::ResourceDetector::Create(resource_attrs, schema_url); +} + +} // namespace resource_detector +OPENTELEMETRY_END_NAMESPACE diff --git a/resource_detectors/include/opentelemetry/resource_detectors/env_entity_detector.h b/resource_detectors/include/opentelemetry/resource_detectors/env_entity_detector.h new file mode 100644 index 0000000000..a01ae07de8 --- /dev/null +++ b/resource_detectors/include/opentelemetry/resource_detectors/env_entity_detector.h @@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "opentelemetry/sdk/resource/resource.h" +#include "opentelemetry/sdk/resource/resource_detector.h" +#include "opentelemetry/version.h" + +OPENTELEMETRY_BEGIN_NAMESPACE +namespace resource_detector +{ + +/** + * EnvEntityDetector detects entities defined in the OTEL_ENTITIES environment + * variable as specified in the Entity Propagation spec: + * https://opentelemetry.io/docs/specs/otel/entities/entity-propagation/ + */ +class EnvEntityDetector : public opentelemetry::sdk::resource::ResourceDetector +{ +public: + opentelemetry::sdk::resource::Resource Detect() noexcept override; +}; + +} // namespace resource_detector +OPENTELEMETRY_END_NAMESPACE diff --git a/resource_detectors/test/BUILD b/resource_detectors/test/BUILD index b230901308..a10d05b02b 100644 --- a/resource_detectors/test/BUILD +++ b/resource_detectors/test/BUILD @@ -7,12 +7,14 @@ cc_test( name = "resource_detector_test", srcs = [ "container_detector_test.cc", + "env_entity_detector_test.cc", "process_detector_test.cc", ], tags = ["test"], deps = [ "//api", "//resource_detectors", + "//sdk/src/resource", "@com_google_googletest//:gtest_main", ], ) diff --git a/resource_detectors/test/CMakeLists.txt b/resource_detectors/test/CMakeLists.txt index 4062a278ef..53c793d045 100644 --- a/resource_detectors/test/CMakeLists.txt +++ b/resource_detectors/test/CMakeLists.txt @@ -1,13 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -add_executable(resource_detector_test container_detector_test.cc - process_detector_test.cc) +add_executable( + resource_detector_test container_detector_test.cc process_detector_test.cc + env_entity_detector_test.cc) # Link the required dependencies target_link_libraries( - resource_detector_test PRIVATE opentelemetry_resource_detectors - opentelemetry_api GTest::gtest_main) + resource_detector_test + PRIVATE opentelemetry_resource_detectors opentelemetry_api + opentelemetry_resources GTest::gtest_main) gtest_add_tests( TARGET resource_detector_test diff --git a/resource_detectors/test/env_entity_detector_test.cc b/resource_detectors/test/env_entity_detector_test.cc new file mode 100644 index 0000000000..33ad7fc0fc --- /dev/null +++ b/resource_detectors/test/env_entity_detector_test.cc @@ -0,0 +1,491 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "opentelemetry/nostd/variant.h" +#include "opentelemetry/resource_detectors/env_entity_detector.h" +#include "opentelemetry/sdk/resource/resource.h" + +#if defined(_MSC_VER) +# include "opentelemetry/sdk/common/env_variables.h" +using opentelemetry::sdk::common::setenv; +using opentelemetry::sdk::common::unsetenv; +#endif + +using namespace opentelemetry::resource_detector; +namespace nostd = opentelemetry::nostd; + +#ifndef NO_GETENV +TEST(EnvEntityDetectorTest, Basic) +{ + setenv("OTEL_ENTITIES", "service{service.name=my-app,service.instance.id=instance-1}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_TRUE(received_attributes.find("service.name") != received_attributes.end()); + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "my-app"); + EXPECT_TRUE(received_attributes.find("service.instance.id") != received_attributes.end()); + EXPECT_EQ(nostd::get(received_attributes.at("service.instance.id")), "instance-1"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, WithDescriptiveAttributes) +{ + setenv("OTEL_ENTITIES", + "service{service.name=my-app,service.instance.id=instance-1}[service.version=1.0.0]", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "my-app"); + EXPECT_EQ(nostd::get(received_attributes.at("service.instance.id")), "instance-1"); + EXPECT_EQ(nostd::get(received_attributes.at("service.version")), "1.0.0"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MultipleEntities) +{ + setenv("OTEL_ENTITIES", + "service{service.name=my-app,service.instance.id=instance-1}[service.version=1.0.0];" + "host{host.id=host-123}[host.name=web-server-01]", + 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "my-app"); + EXPECT_EQ(nostd::get(received_attributes.at("service.instance.id")), "instance-1"); + EXPECT_EQ(nostd::get(received_attributes.at("service.version")), "1.0.0"); + EXPECT_EQ(nostd::get(received_attributes.at("host.id")), "host-123"); + EXPECT_EQ(nostd::get(received_attributes.at("host.name")), "web-server-01"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, PercentEncoding) +{ + setenv("OTEL_ENTITIES", "service{service.name=my%2Capp,service.instance.id=inst-1}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "my,app"); + EXPECT_EQ(nostd::get(received_attributes.at("service.instance.id")), "inst-1"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, DuplicateEntities) +{ + setenv("OTEL_ENTITIES", + "service{service.name=app1}[version=1.0];service{service.name=app1}[version=2.0]", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Last occurrence should win + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(nostd::get(received_attributes.at("version")), "2.0"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, EmptyEnv) +{ + unsetenv("OTEL_ENTITIES"); + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_TRUE(received_attributes.empty()); +} + +TEST(EnvEntityDetectorTest, EmptyString) +{ + setenv("OTEL_ENTITIES", "", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MalformedEntity) +{ + setenv("OTEL_ENTITIES", "service{service.name=app1};invalid{syntax;service{service.name=app2}", + 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Should process valid entities and skip malformed ones + EXPECT_TRUE(received_attributes.find("service.name") != received_attributes.end()); + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app2"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, WhitespaceHandling) +{ + setenv("OTEL_ENTITIES", " ; service { service.name = app1 } ; ", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, EmptySemicolons) +{ + // Test: Empty strings are allowed (leading, trailing, and consecutive semicolons are ignored) + setenv("OTEL_ENTITIES", ";service{service.name=app1};;host{host.id=host-123};", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(nostd::get(received_attributes.at("host.id")), "host-123"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MissingRequiredFields) +{ + // Test: Missing required fields (type or identifying attributes) - should skip entity + setenv("OTEL_ENTITIES", "service{};host{host.id=123}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_TRUE(received_attributes.find("service.name") == received_attributes.end()); + EXPECT_EQ(nostd::get(received_attributes.at("host.id")), "123"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ConflictingIdentifyingAttributes) +{ + // Test: Conflicting identifying attributes - last entity wins + setenv("OTEL_ENTITIES", "service{service.name=app1};service{service.name=app2}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Last entity should win + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app2"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ConflictingDescriptiveAttributes) +{ + // Test: Conflicting descriptive attributes - last entity's value is used + setenv("OTEL_ENTITIES", + "service{service.name=app1}[version=1.0];service{service.name=app2}[version=2.0]", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app2"); + EXPECT_EQ(nostd::get(received_attributes.at("version")), "2.0"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, KubernetesPod) +{ + // Test: Kubernetes pod entity example from spec + setenv("OTEL_ENTITIES", + "k8s.pod{k8s.pod.uid=pod-abc123}[k8s.pod.name=my-pod,k8s.pod.label.app=my-app]", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("k8s.pod.uid")), "pod-abc123"); + EXPECT_EQ(nostd::get(received_attributes.at("k8s.pod.name")), "my-pod"); + EXPECT_EQ(nostd::get(received_attributes.at("k8s.pod.label.app")), "my-app"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ContainerWithHost) +{ + // Test: Container with host (minimal descriptive attributes) + setenv("OTEL_ENTITIES", + "container{container.id=cont-456};host{host.id=host-789}[host.name=docker-host]", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("container.id")), "cont-456"); + EXPECT_EQ(nostd::get(received_attributes.at("host.id")), "host-789"); + EXPECT_EQ(nostd::get(received_attributes.at("host.name")), "docker-host"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MinimalEntity) +{ + // Test: Minimal entity (only required fields) + setenv("OTEL_ENTITIES", "service{service.name=minimal-app}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "minimal-app"); + EXPECT_EQ(received_attributes.size(), 1); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, PercentEncodingMultiple) +{ + // Test: Multiple percent-encoded characters in one value + setenv("OTEL_ENTITIES", + "service{service.name=my%2Capp,service.instance.id=inst-1}[config=key%3Dvalue%5Bprod%5D]", + 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "my,app"); + EXPECT_EQ(nostd::get(received_attributes.at("config")), "key=value[prod]"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, SchemaUrlRelativePath) +{ + // Test: Relative path schema URL should be accepted + setenv("OTEL_ENTITIES", "service{service.name=app1}@schemas/1.21.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed and relative schema URL accepted + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(resource.GetSchemaURL(), "schemas/1.21.0"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, InvalidSchemaUrlEmptyScheme) +{ + setenv("OTEL_ENTITIES", "service{service.name=app1}@://example.com/schemas/1.0.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed but schema URL ignored (invalid scheme) + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_TRUE(resource.GetSchemaURL().empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, InvalidSchemaUrlInvalidScheme) +{ + setenv("OTEL_ENTITIES", "service{service.name=app1}@123://example.com/schemas/1.0.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed but schema URL ignored (scheme must start with letter) + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_TRUE(resource.GetSchemaURL().empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, AllMalformedEntities) +{ + // Test: All entities are malformed - ParseEntities returns empty, should return empty resource + setenv("OTEL_ENTITIES", "invalid{syntax};{missing-type};123{invalid-type}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // All entities are invalid, so parsed_entities.empty() is true, should return empty resource + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, EmptySchemaUrl) +{ + // Test: Empty schema URL - should log warning and ignore URL + setenv("OTEL_ENTITIES", "service{service.name=app1}@", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed but empty schema URL ignored + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ValidSchemaUrlAbsolute) +{ + // Test: Valid absolute schema URL with "://" - should be accepted + setenv("OTEL_ENTITIES", "service{service.name=app1}@https://opentelemetry.io/schemas/1.0.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed and schema URL should be valid (not cleared) + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(resource.GetSchemaURL(), "https://opentelemetry.io/schemas/1.0.0"); + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ValidSchemaUrlRelative) +{ + setenv("OTEL_ENTITIES", "service{service.name=app1}@/schemas/1.21.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(resource.GetSchemaURL(), "/schemas/1.21.0"); + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, ValidSchemaUrlHttp) +{ + setenv("OTEL_ENTITIES", "service{service.name=app1}@http://example.com/schemas/1.0.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + EXPECT_EQ(resource.GetSchemaURL(), "http://example.com/schemas/1.0.0"); + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, SchemaUrlWithWhitespace) +{ + // Test: Schema URL with only whitespace - should be treated as empty after trim + setenv("OTEL_ENTITIES", "service{service.name=app1}@ ", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity should be processed but whitespace-only schema URL should be ignored + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MissingClosingBracket) +{ + // Test: Missing closing bracket ']' for desc_attrs - should be rejected + setenv("OTEL_ENTITIES", "service{service.name=app1}[version=1.0", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity with missing closing bracket should be rejected + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, MissingClosingBrace) +{ + // Test: Missing closing brace '}' for id_attrs - should be rejected + setenv("OTEL_ENTITIES", "service{service.name=app1", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity with missing closing brace should be rejected + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, InvalidTypeCharacters) +{ + // Test: Type with invalid characters (not alphanumeric, '.', '_', or '-') + setenv("OTEL_ENTITIES", "service@name{service.name=app1}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // Entity with invalid type characters should be rejected + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, EmptyKeyInAttributes) +{ + // Test: Key-value pair with empty key after trimming - should be skipped + setenv("OTEL_ENTITIES", "service{=value,service.name=app1, =another}", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + EXPECT_EQ(nostd::get(received_attributes.at("service.name")), "app1"); + // Empty key entries should not appear in attributes + EXPECT_EQ(received_attributes.size(), 1); + + unsetenv("OTEL_ENTITIES"); +} + +TEST(EnvEntityDetectorTest, EmptyEntityString) +{ + // Test: Only empty entity strings - should all be skipped and return empty resource + setenv("OTEL_ENTITIES", ";;;", 1); + + EnvEntityDetector detector; + auto resource = detector.Detect(); + const auto &received_attributes = resource.GetAttributes(); + + // All empty strings should be skipped, resulting in empty resource + EXPECT_TRUE(received_attributes.empty()); + + unsetenv("OTEL_ENTITIES"); +} +#endif