Skip to content

Commit 1d75005

Browse files
committed
[SDK] Implement EnvEntityDetector for OTEL_ENTITIES parsing (#3652)
Changes: - Add EnvEntityDetector class to parse OTEL_ENTITIES environment variable - Parse entity format: type{id_attrs}[desc_attrs]@schema_url - Support percent-encoding for reserved characters - Handle duplicate entities, conflicting attributes, and malformed input per spec - Include comprehensive test coverage Details: - EnvEntityDetector::Detect() reads OTEL_ENTITIES and returns Resource with parsed attributes - ParseEntities() splits input by semicolons and parses each entity definition - ParseSingleEntity() extracts type, id_attrs, desc_attrs, and schema_url from entity string - ParseKeyValueList() parses comma-separated key=value pairs - PercentDecode() decodes percent-encoded values - BuildEntityIdentityKey() creates stable key for duplicate detection - Error handling: malformed entities skipped, duplicates use last occurrence, conflicts log warnings Implements entity propagation spec: https://opentelemetry.io/docs/specs/otel/entities/entity-propagation/
1 parent 162246f commit 1d75005

File tree

4 files changed

+623
-1
lines changed

4 files changed

+623
-1
lines changed

sdk/include/opentelemetry/sdk/resource/resource_detector.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ class OTELResourceDetector : public ResourceDetector
3939
Resource Detect() noexcept override;
4040
};
4141

42+
/**
43+
* EnvEntityDetector detects entities defined in the OTEL_ENTITIES environment
44+
* variable as specified in the Entity Propagation spec:
45+
* https://opentelemetry.io/docs/specs/otel/entities/entity-propagation/
46+
*/
47+
class EnvEntityDetector : public ResourceDetector
48+
{
49+
public:
50+
Resource Detect() noexcept override;
51+
};
52+
4253
} // namespace resource
4354
} // namespace sdk
4455
OPENTELEMETRY_END_NAMESPACE

sdk/src/resource/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright The OpenTelemetry Authors
22
# SPDX-License-Identifier: Apache-2.0
33

4-
add_library(opentelemetry_resources resource.cc resource_detector.cc)
4+
add_library(opentelemetry_resources resource.cc resource_detector.cc env_entity_detector.cc)
55

66
set_target_properties(opentelemetry_resources PROPERTIES EXPORT_NAME resources)
77
set_target_version(opentelemetry_resources)
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#include "opentelemetry/sdk/resource/resource_detector.h"
5+
6+
#include "opentelemetry/sdk/common/env_variables.h"
7+
#include "opentelemetry/sdk/common/global_log_handler.h"
8+
#include "opentelemetry/sdk/common/attribute_utils.h"
9+
#include "opentelemetry/common/string_util.h"
10+
#include "opentelemetry/sdk/resource/resource.h"
11+
12+
#include <cctype>
13+
#include <sstream>
14+
#include <string>
15+
#include <unordered_map>
16+
#include <vector>
17+
18+
OPENTELEMETRY_BEGIN_NAMESPACE
19+
namespace sdk
20+
{
21+
namespace resource
22+
{
23+
24+
namespace
25+
{
26+
27+
constexpr const char *kOtelEntities = "OTEL_ENTITIES";
28+
29+
struct ParsedEntity
30+
{
31+
std::string type;
32+
ResourceAttributes id_attrs;
33+
ResourceAttributes desc_attrs;
34+
std::string schema_url;
35+
std::string identity_key; // Pre-computed identity key for duplicate detection
36+
};
37+
38+
39+
std::string BuildEntityIdentityKey(const std::string &type, const ResourceAttributes &id_attrs)
40+
{
41+
using AttrPtr = const std::pair<const std::string, opentelemetry::sdk::common::OwnedAttributeValue> *;
42+
std::vector<AttrPtr> items;
43+
items.reserve(id_attrs.size());
44+
for (const auto &kv : id_attrs)
45+
{
46+
items.push_back(&kv);
47+
}
48+
std::sort(items.begin(), items.end(),
49+
[](AttrPtr a, AttrPtr b) { return a->first < b->first; });
50+
51+
std::string key = type + "|";
52+
for (size_t i = 0; i < items.size(); ++i)
53+
{
54+
if (i > 0)
55+
{
56+
key += ",";
57+
}
58+
key += items[i]->first;
59+
key += "=";
60+
key += nostd::get<std::string>(items[i]->second);
61+
}
62+
return key;
63+
}
64+
65+
66+
std::string PercentDecode(nostd::string_view value) noexcept
67+
{
68+
if (value.find('%') == nostd::string_view::npos)
69+
{
70+
return std::string(value);
71+
}
72+
73+
std::string result;
74+
result.reserve(value.size());
75+
76+
auto IsHex = [](char c) {
77+
return std::isdigit(static_cast<unsigned char>(c)) ||
78+
(c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f');
79+
};
80+
81+
auto FromHex = [](char c) -> char {
82+
return static_cast<char>(std::isdigit(static_cast<unsigned char>(c))
83+
? c - '0'
84+
: std::toupper(static_cast<unsigned char>(c)) - 'A' + 10);
85+
};
86+
87+
for (size_t i = 0; i < value.size(); ++i)
88+
{
89+
if (value[i] == '%' && i + 2 < value.size() && IsHex(value[i + 1]) && IsHex(value[i + 2]))
90+
{
91+
result.push_back(static_cast<char>((FromHex(value[i + 1]) << 4) | FromHex(value[i + 2])));
92+
i += 2;
93+
}
94+
else
95+
{
96+
result.push_back(value[i]);
97+
}
98+
}
99+
100+
return result;
101+
}
102+
103+
104+
void ParseKeyValueList(const std::string &input, ResourceAttributes &out)
105+
{
106+
std::istringstream iss(input);
107+
std::string token;
108+
while (std::getline(iss, token, ','))
109+
{
110+
token = std::string{opentelemetry::common::StringUtil::Trim(token)};
111+
if (token.empty())
112+
{
113+
continue;
114+
}
115+
size_t pos = token.find('=');
116+
if (pos == std::string::npos)
117+
{
118+
continue;
119+
}
120+
std::string key = token.substr(0, pos);
121+
std::string value = token.substr(pos + 1);
122+
key = std::string{opentelemetry::common::StringUtil::Trim(key)};
123+
value = std::string{opentelemetry::common::StringUtil::Trim(value)};
124+
if (key.empty())
125+
{
126+
continue;
127+
}
128+
out[key] = PercentDecode(value);
129+
}
130+
}
131+
132+
bool ParseSingleEntity(const std::string &entity_str, ParsedEntity &out)
133+
{
134+
if (entity_str.empty())
135+
{
136+
return false;
137+
}
138+
139+
// type is everything before first '{'
140+
size_t brace_pos = entity_str.find('{');
141+
if (brace_pos == std::string::npos || brace_pos == 0)
142+
{
143+
return false;
144+
}
145+
146+
out.type = std::string{opentelemetry::common::StringUtil::Trim(
147+
entity_str.substr(0, brace_pos))};
148+
149+
// Validate type matches [a-zA-Z][a-zA-Z0-9._-]*
150+
if (out.type.empty() || !std::isalpha(static_cast<unsigned char>(out.type[0])))
151+
{
152+
return false;
153+
}
154+
for (size_t i = 1; i < out.type.size(); ++i)
155+
{
156+
char c = out.type[i];
157+
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' || c == '-'))
158+
{
159+
return false;
160+
}
161+
}
162+
163+
// Extract id_attrs in {...}
164+
size_t id_start = brace_pos + 1;
165+
size_t id_end = entity_str.find('}', id_start);
166+
if (id_end == std::string::npos || id_end <= id_start)
167+
{
168+
return false;
169+
}
170+
std::string id_block = std::string{opentelemetry::common::StringUtil::Trim(
171+
entity_str.substr(id_start, id_end - id_start))};
172+
ParseKeyValueList(id_block, out.id_attrs);
173+
if (out.id_attrs.empty())
174+
{
175+
return false;
176+
}
177+
178+
// Pre-compute identity key for duplicate detection.
179+
out.identity_key = BuildEntityIdentityKey(out.type, out.id_attrs);
180+
181+
size_t cursor = id_end + 1;
182+
183+
// Optional desc_attrs in [...]
184+
if (cursor < entity_str.size() && entity_str[cursor] == '[')
185+
{
186+
size_t desc_start = cursor + 1;
187+
size_t desc_end = entity_str.find(']', desc_start);
188+
if (desc_end == std::string::npos || desc_end <= desc_start)
189+
{
190+
return false;
191+
}
192+
std::string desc_block = std::string{opentelemetry::common::StringUtil::Trim(
193+
entity_str.substr(desc_start, desc_end - desc_start))};
194+
ParseKeyValueList(desc_block, out.desc_attrs);
195+
cursor = desc_end + 1;
196+
}
197+
198+
// Optional schema URL: '@...'
199+
if (cursor < entity_str.size() && entity_str[cursor] == '@')
200+
{
201+
out.schema_url = std::string{opentelemetry::common::StringUtil::Trim(
202+
entity_str.substr(cursor + 1))};
203+
204+
// TODO: Use a proper Schema URL validator when available.
205+
if (out.schema_url.empty() || out.schema_url.find("://") == std::string::npos)
206+
{
207+
OTEL_INTERNAL_LOG_WARN(
208+
"[EnvEntityDetector] Invalid schema URL in OTEL_ENTITIES, ignoring schema URL.");
209+
out.schema_url.clear();
210+
}
211+
}
212+
213+
return true;
214+
}
215+
216+
std::vector<ParsedEntity> ParseEntities(const std::string &entities_str)
217+
{
218+
std::vector<ParsedEntity> entities;
219+
220+
std::istringstream iss(entities_str);
221+
std::string token;
222+
while (std::getline(iss, token, ';'))
223+
{
224+
token = std::string{opentelemetry::common::StringUtil::Trim(token)};
225+
if (token.empty())
226+
{
227+
continue;
228+
}
229+
ParsedEntity entity;
230+
if (ParseSingleEntity(token, entity))
231+
{
232+
entities.push_back(std::move(entity));
233+
}
234+
else
235+
{
236+
OTEL_INTERNAL_LOG_WARN(
237+
"[EnvEntityDetector] Skipping malformed entity definition in OTEL_ENTITIES.");
238+
}
239+
}
240+
241+
return entities;
242+
}
243+
244+
} // namespace
245+
246+
Resource EnvEntityDetector::Detect() noexcept
247+
{
248+
std::string entities_str;
249+
bool exists =
250+
opentelemetry::sdk::common::GetStringEnvironmentVariable(kOtelEntities, entities_str);
251+
252+
if (!exists || entities_str.empty())
253+
{
254+
return ResourceDetector::Create({});
255+
}
256+
257+
auto parsed_entities = ParseEntities(entities_str);
258+
if (parsed_entities.empty())
259+
{
260+
return ResourceDetector::Create({});
261+
}
262+
263+
ResourceAttributes resource_attrs;
264+
265+
std::unordered_map<std::string, size_t> entity_index_by_identity;
266+
entity_index_by_identity.reserve(parsed_entities.size());
267+
for (size_t i = 0; i < parsed_entities.size(); ++i)
268+
{
269+
const std::string &identity_key = parsed_entities[i].identity_key;
270+
auto it = entity_index_by_identity.find(identity_key);
271+
if (it != entity_index_by_identity.end())
272+
{
273+
OTEL_INTERNAL_LOG_WARN(
274+
"[EnvEntityDetector] Duplicate entity definition in OTEL_ENTITIES, using last "
275+
"occurrence.");
276+
it->second = i;
277+
continue;
278+
}
279+
entity_index_by_identity.emplace(identity_key, i);
280+
}
281+
282+
for (size_t i = 0; i < parsed_entities.size(); ++i)
283+
{
284+
const std::string &identity_key = parsed_entities[i].identity_key;
285+
auto it = entity_index_by_identity.find(identity_key);
286+
287+
// Only process if this is the last occurrence for this identity.
288+
if (it == entity_index_by_identity.end() || it->second != i)
289+
{
290+
continue;
291+
}
292+
293+
const auto &entity = parsed_entities[i];
294+
295+
// Add identifying attributes.
296+
for (const auto &attr : entity.id_attrs)
297+
{
298+
auto existing = resource_attrs.find(attr.first);
299+
if (existing != resource_attrs.end() &&
300+
nostd::get<std::string>(existing->second) != nostd::get<std::string>(attr.second))
301+
{
302+
OTEL_INTERNAL_LOG_WARN(
303+
"[EnvEntityDetector] Conflicting identifying attribute in OTEL_ENTITIES, "
304+
"preserving value from last entity.");
305+
}
306+
resource_attrs[attr.first] = attr.second;
307+
}
308+
309+
// Add descriptive attributes.
310+
for (const auto &attr : entity.desc_attrs)
311+
{
312+
auto existing = resource_attrs.find(attr.first);
313+
if (existing != resource_attrs.end() &&
314+
nostd::get<std::string>(existing->second) != nostd::get<std::string>(attr.second))
315+
{
316+
OTEL_INTERNAL_LOG_WARN(
317+
"[EnvEntityDetector] Conflicting descriptive attribute in OTEL_ENTITIES, "
318+
"using value from last entity.");
319+
}
320+
resource_attrs[attr.first] = attr.second;
321+
}
322+
}
323+
324+
return ResourceDetector::Create(resource_attrs);
325+
}
326+
327+
} // namespace resource
328+
} // namespace sdk
329+
OPENTELEMETRY_END_NAMESPACE
330+
331+

0 commit comments

Comments
 (0)