Skip to content

Commit 53d0694

Browse files
committed
Add entities prototype
Fixes #4161
1 parent f15821f commit 53d0694

File tree

6 files changed

+426
-20
lines changed

6 files changed

+426
-20
lines changed

.github/workflows/generate_workflows.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from pathlib import Path
22

33
from generate_workflows_lib import (
4-
generate_test_workflow,
5-
generate_lint_workflow,
64
generate_contrib_workflow,
7-
generate_misc_workflow
5+
generate_lint_workflow,
6+
generate_misc_workflow,
7+
generate_test_workflow,
88
)
99

1010
tox_ini_path = Path(__file__).parent.parent.parent.joinpath("tox.ini")

opentelemetry-sdk/pyproject.toml

+4
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ otel = "opentelemetry.sdk.resources:OTELResourceDetector"
6969
process = "opentelemetry.sdk.resources:ProcessResourceDetector"
7070
os = "opentelemetry.sdk.resources:OsResourceDetector"
7171

72+
[project.entry-points.opentelemetry_entity_detector]
73+
type0= "opentelemetry.sdk.resources:Type0EntityDetector"
74+
type1= "opentelemetry.sdk.resources:Type1EntityDetector"
75+
7276
[project.urls]
7377
Homepage = "https://github.com/open-telemetry/opentelemetry-python/tree/main/opentelemetry-sdk"
7478

opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py

+259-12
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,18 @@
5555
above example.
5656
"""
5757

58-
import abc
5958
import concurrent.futures
6059
import logging
6160
import os
6261
import platform
6362
import sys
64-
import typing
63+
from abc import ABC, abstractmethod
6564
from json import dumps
6665
from os import environ
6766
from types import ModuleType
68-
from typing import List, MutableMapping, Optional, cast
67+
from typing import List, Mapping, MutableMapping, Optional, cast
6968
from urllib import parse
69+
from warnings import warn
7070

7171
from opentelemetry.attributes import BoundedAttributes
7272
from opentelemetry.sdk.environment_variables import (
@@ -88,7 +88,7 @@
8888
pass
8989

9090
LabelValue = AttributeValue
91-
Attributes = typing.Mapping[str, LabelValue]
91+
Attributes = Mapping[str, LabelValue]
9292
logger = logging.getLogger(__name__)
9393

9494
CLOUD_PROVIDER = ResourceAttributes.CLOUD_PROVIDER
@@ -152,24 +152,90 @@
152152
_OPENTELEMETRY_SDK_VERSION: str = version("opentelemetry-sdk")
153153

154154

155+
class Entity:
156+
157+
def __init__(
158+
self,
159+
type_: str,
160+
id_: Mapping[str, str],
161+
attributes: Optional[Attributes] = None,
162+
schema_url: Optional[str] = None,
163+
):
164+
165+
if not type_:
166+
raise Exception("Entity type must not be empty")
167+
168+
if attributes is None:
169+
attributes = {}
170+
171+
self._type = type_
172+
173+
# These are attributes that identify the entity and must not change
174+
# during the lifetime of the entity. id_ must contain at least one
175+
# attribute.
176+
if not id_:
177+
raise Exception("Entity id must not be empty")
178+
179+
self._id = id_
180+
181+
# These are attributes that do not identify the entity and may change
182+
# during the lifetime of the entity.
183+
self._attributes = attributes
184+
185+
if schema_url is None:
186+
schema_url = ""
187+
188+
self._schema_url = schema_url
189+
190+
@property
191+
def type(self):
192+
return self._type
193+
194+
@property
195+
def id(self):
196+
# FIXME we need a checker here that makes sure that the id attributes
197+
# are compliant with the spec. Not implementing it here since this
198+
# seems like a thing that should be available to other components as
199+
# well.
200+
return self._id
201+
202+
@property
203+
def attributes(self):
204+
return self._attributes
205+
206+
@property
207+
def schema_url(self):
208+
return self._schema_url
209+
210+
155211
class Resource:
156212
"""A Resource is an immutable representation of the entity producing telemetry as Attributes."""
157213

158214
_attributes: BoundedAttributes
159215
_schema_url: str
160216

161217
def __init__(
162-
self, attributes: Attributes, schema_url: typing.Optional[str] = None
218+
self,
219+
attributes: Attributes,
220+
schema_url: Optional[str] = None,
221+
*entities,
163222
):
164223
self._attributes = BoundedAttributes(attributes=attributes)
165224
if schema_url is None:
166225
schema_url = ""
226+
else:
227+
warn("Resource.schema_url is deprecated", DeprecationWarning)
228+
167229
self._schema_url = schema_url
168230

231+
# FIXME the spec draft says the Resource will have an EntityRef proto
232+
# figure out what that is supposed to be.
233+
self._entities = entities
234+
169235
@staticmethod
170236
def create(
171-
attributes: typing.Optional[Attributes] = None,
172-
schema_url: typing.Optional[str] = None,
237+
attributes: Optional[Attributes] = None,
238+
schema_url: Optional[str] = None,
173239
) -> "Resource":
174240
"""Creates a new `Resource` from attributes.
175241
@@ -181,7 +247,7 @@ def create(
181247
The newly-created Resource.
182248
"""
183249

184-
if not attributes:
250+
if attributes is None:
185251
attributes = {}
186252

187253
otel_experimental_resource_detectors = {"otel"}.union(
@@ -225,6 +291,51 @@ def create(
225291
)
226292
return resource
227293

294+
@staticmethod
295+
def create_using_entities(
296+
attributes: Optional[Attributes] = None,
297+
schema_url: Optional[str] = None,
298+
) -> "Resource":
299+
# This method is added here with the intention of not disturbing the
300+
# previous API create method for backwards compatibility reasons.
301+
302+
if attributes is None:
303+
attributes = {}
304+
305+
if schema_url is None:
306+
schema_url = ""
307+
308+
selected_entities = _select_entities(
309+
[
310+
entity_detector.detect()
311+
for entity_detector in _get_entity_detectors()
312+
]
313+
)
314+
315+
resource_attributes = {}
316+
317+
for selected_entity in selected_entities:
318+
for key, value in selected_entity._id.items():
319+
resource_attributes[key] = value
320+
321+
for key, value in selected_entity._attributes.items():
322+
resource_attributes[key] = value
323+
324+
entity_schema_url = selected_entities[0].schema_url
325+
326+
for selected_entity in selected_entities:
327+
if selected_entity.schema_url != entity_schema_url:
328+
entity_schema_url = None
329+
break
330+
331+
resource_attributes.update(attributes)
332+
333+
resource = Resource.create(
334+
attributes=resource_attributes, schema_url=entity_schema_url
335+
)
336+
337+
return resource
338+
228339
@staticmethod
229340
def get_empty() -> "Resource":
230341
return _EMPTY_RESOURCE
@@ -298,6 +409,72 @@ def to_json(self, indent: int = 4) -> str:
298409
)
299410

300411

412+
def _get_entity_detectors():
413+
414+
entity_detectors: List[ResourceDetector] = []
415+
416+
for entity_detector in entry_points(
417+
group="opentelemetry_entity_detector",
418+
):
419+
420+
entity_detectors.append(entity_detector.load()())
421+
422+
# This checker is added here but it could live in the configuration
423+
# mechanism, so that it detects a possible error when 2 entity
424+
# detectors have the same priority even earlier.
425+
if len(entity_detectors) > 1:
426+
427+
sorted_entity_detectors = sorted(
428+
entity_detectors, key=lambda x: x.priority
429+
)
430+
431+
priorities = set()
432+
433+
for entity_detector in sorted_entity_detectors:
434+
435+
if entity_detector.priority in priorities:
436+
raise ValueError(
437+
f"Duplicate priority {entity_detector.priority}"
438+
f"for entity detector of type {type(entity_detector)}"
439+
)
440+
441+
priorities.add(entity_detector.priority)
442+
443+
return entity_detectors
444+
445+
446+
def _select_entities(unselected_entities):
447+
448+
selected_entities = [unselected_entities.pop(0)]
449+
450+
for unselected_entity in unselected_entities:
451+
452+
for selected_entity in selected_entities:
453+
454+
if selected_entity.type == unselected_entity.type:
455+
if (
456+
selected_entity.id == unselected_entity.id
457+
and selected_entity.schema_url
458+
== (unselected_entity.schema_url)
459+
):
460+
for key, value in unselected_entity.attributes.items():
461+
if key not in selected_entity.attributes.keys():
462+
selected_entity._attributes[key] = value
463+
break
464+
elif (
465+
selected_entity.id == unselected_entity.id
466+
and selected_entity.schema_url
467+
!= (unselected_entity.schema_url)
468+
):
469+
break
470+
elif selected_entity.id != unselected_entity.id:
471+
break
472+
else:
473+
selected_entities.append(unselected_entity)
474+
475+
return selected_entities
476+
477+
301478
_EMPTY_RESOURCE = Resource({})
302479
_DEFAULT_RESOURCE = Resource(
303480
{
@@ -308,19 +485,89 @@ def to_json(self, indent: int = 4) -> str:
308485
)
309486

310487

311-
class ResourceDetector(abc.ABC):
488+
class Detector(ABC):
312489
def __init__(self, raise_on_error: bool = False) -> None:
313490
self.raise_on_error = raise_on_error
314491

315-
@abc.abstractmethod
492+
@abstractmethod
493+
def detect(self):
494+
raise NotImplementedError()
495+
496+
497+
class ResourceDetector(Detector):
498+
@abstractmethod
316499
def detect(self) -> "Resource":
317500
raise NotImplementedError()
318501

319502

503+
class EntityDetector(Detector):
504+
def __init__(self, raise_on_error: bool = False) -> None:
505+
self.raise_on_error = raise_on_error
506+
507+
@abstractmethod
508+
def detect(self) -> "Entity":
509+
raise NotImplementedError()
510+
511+
@property
512+
@abstractmethod
513+
def priority(self):
514+
raise NotImplementedError()
515+
516+
517+
class Type0EntityDetector(EntityDetector):
518+
519+
_entity = None
520+
521+
def detect(self) -> Entity:
522+
523+
if self._entity is None:
524+
# The OTEP says an entity detector must not provide two entities of
525+
# the same type. It seems to me that this means it will only
526+
# provide one entity at all since the entity detector is associated
527+
# to a particular "type" (process, OS, etc)
528+
self._entity = Entity(
529+
"type0", id_={"a": "b"}, attributes={"c": "d"}
530+
)
531+
532+
return self._entity
533+
534+
@property
535+
def priority(self):
536+
# This probably needs a configuration mechanism so that it can get its
537+
# priority from some configuration file or something else.
538+
return 0
539+
540+
541+
class Type1EntityDetector(EntityDetector):
542+
543+
_entity = None
544+
545+
def detect(self) -> Entity:
546+
547+
if self._entity is None:
548+
# The OTEP says an entity detector must not provide two entities of
549+
# the same type. It seems to me that this means it will only
550+
# provide one entity at all since the entity detector is associated
551+
# to a particular "type" (process, OS, etc)
552+
553+
self._entity = Entity(
554+
"type1", id_={"a": "b"}, attributes={"c": "d"}
555+
)
556+
557+
return self._entity
558+
559+
@property
560+
def priority(self):
561+
# This probably needs a configuration mechanism so that it can get its
562+
# priority from some configuration file or something else.
563+
return 1
564+
565+
320566
class OTELResourceDetector(ResourceDetector):
321567
# pylint: disable=no-self-use
322568
def detect(self) -> "Resource":
323569

570+
# An example of OTEL_RESOURCE_ATTRIBUTES is "key0=value0,key1=value1"
324571
env_resources_items = environ.get(OTEL_RESOURCE_ATTRIBUTES)
325572
env_resource_map = {}
326573

@@ -472,8 +719,8 @@ def detect(self) -> "Resource":
472719

473720

474721
def get_aggregated_resources(
475-
detectors: typing.List["ResourceDetector"],
476-
initial_resource: typing.Optional[Resource] = None,
722+
detectors: List["ResourceDetector"],
723+
initial_resource: Optional[Resource] = None,
477724
timeout: int = 5,
478725
) -> "Resource":
479726
"""Retrieves resources from detectors in the order that they were passed

0 commit comments

Comments
 (0)