odigos-opentelemetry-python 1.0.81__tar.gz → 1.0.83__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/PKG-INFO +1 -1
  2. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/components.py +51 -40
  3. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/odigos_sampler.py +18 -3
  4. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/schemas/sampling.py +1 -0
  5. odigos_opentelemetry_python-1.0.83/initializer/version.py +1 -0
  6. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/PKG-INFO +1 -1
  7. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/config.py +14 -7
  8. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/http_client.py +101 -86
  9. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/pyproject.toml +4 -1
  10. odigos_opentelemetry_python-1.0.81/initializer/version.py +0 -1
  11. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/LICENSE +0 -0
  12. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/README.md +0 -0
  13. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/__init__.py +0 -0
  14. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/distro/__init__.py +0 -0
  15. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/distro/distro.py +0 -0
  16. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/distro/instrumentation_registry.py +0 -0
  17. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/envs.py +0 -0
  18. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/exit_hook.py +0 -0
  19. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/lib_handling.py +0 -0
  20. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/post_instrument.py +0 -0
  21. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/process_resource.py +0 -0
  22. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/initializer/schemas/__init__.py +0 -0
  23. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/SOURCES.txt +0 -0
  24. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/dependency_links.txt +0 -0
  25. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/entry_points.txt +0 -0
  26. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/requires.txt +0 -0
  27. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/odigos_opentelemetry_python.egg-info/top_level.txt +0 -0
  28. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/__init__.py +0 -0
  29. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/anyvalue_pb2.py +0 -0
  30. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/health_status.py +0 -0
  31. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/opamp_pb2.py +0 -0
  32. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/opamp_registry.py +0 -0
  33. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/opamp/utils.py +0 -0
  34. {odigos_opentelemetry_python-1.0.81 → odigos_opentelemetry_python-1.0.83}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.81
3
+ Version: 1.0.83
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author-email: odigos-io <support@odigos.io>
6
6
  Requires-Python: >=3.9
@@ -27,7 +27,6 @@ from opentelemetry.sdk.resources import OTELResourceDetector
27
27
  from opentelemetry.sdk.trace import TracerProvider
28
28
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
29
29
  from opentelemetry.trace import set_tracer_provider, get_tracer_provider
30
- from opentelemetry.sdk.trace.sampling import ParentBased
31
30
 
32
31
 
33
32
  # Move Odigos agent paths to the end of sys.path so the application's packages are
@@ -48,19 +47,19 @@ from opamp.config import Config
48
47
  from opamp import opamp_registry
49
48
  from .distro import instrumentation_registry
50
49
 
51
-
52
50
  from opamp.http_client import OpAMPHTTPClient, MockOpAMPClient
51
+ from opamp.utils import parse_first_message_signals
53
52
 
54
53
  MINIMUM_PYTHON_SUPPORTED_VERSION = (3, 9)
55
54
 
56
55
  _odigos_sampler = None
57
56
 
58
57
 
59
- def initialize_components(trace_exporters = False, span_processor = None):
58
+ def initialize_components(trace_exporters=False, span_processor=None):
60
59
  # Ensure each child process after fork gets a fresh OpAMP client
61
60
  # and correct process.* resource attributes.
62
61
  os.register_at_fork(
63
- after_in_child=_after_in_child,
62
+ after_in_child=_after_in_child,
64
63
  ) # pylint: disable=protected-access
65
64
 
66
65
  handle_instrumenation_of_sub_processes()
@@ -69,24 +68,29 @@ def initialize_components(trace_exporters = False, span_processor = None):
69
68
  client = None
70
69
 
71
70
  try:
72
-
73
71
  client = start_opamp_client(opamp_connection_event)
74
72
 
75
73
  ## In case of error, e.g during the first connection to the OpAMP server,
76
74
  # we will use the default value which is enable traces only.
77
75
  if opamp_connection_event.error:
76
+ container_config = None
78
77
  supported_signals = {"traceSignal": True}
79
- initial_sampler_config = None # No config available due to connection error
78
+ initial_sampler_config = None
79
+ initial_remote_config = None
80
80
  else:
81
- supported_signals = client.signals
81
+ container_config = client.container_config
82
+ supported_signals = parse_first_message_signals(container_config)
82
83
 
83
- # Get initial sampler config directly from client (only when connection is successful)
84
+ # Get initial configs directly from client (only when connection is successful)
84
85
  try:
85
86
  initial_sampler_config = client.get_initial_sampler_config()
86
87
  except AttributeError:
87
- # Fallback to None to avoid errors
88
88
  initial_sampler_config = None
89
89
 
90
+ try:
91
+ initial_remote_config = client.get_initial_remote_config()
92
+ except AttributeError:
93
+ initial_remote_config = None
90
94
 
91
95
  handle_django_instrumentation()
92
96
 
@@ -96,15 +100,20 @@ def initialize_components(trace_exporters = False, span_processor = None):
96
100
  }
97
101
 
98
102
  base_resource = (
99
- OdigosProcessResourceDetector(client.pid).detect()
100
- .merge(OTELResourceDetector().detect())
101
- .merge(Resource.create(auto_resource))
103
+ OdigosProcessResourceDetector(client.pid).detect().merge(OTELResourceDetector().detect()).merge(Resource.create(auto_resource))
102
104
  )
103
105
 
104
106
  # Wrap in proxy so child processes can refresh automatically
105
107
  resource = ProxyResource(base_resource, OdigosProcessResourceDetector)
106
108
 
107
- odigos_sampler = initialize_traces_if_enabled(trace_exporters, resource, span_processor, supported_signals, initial_sampler_config)
109
+ odigos_sampler = initialize_traces_if_enabled(
110
+ trace_exporters,
111
+ resource,
112
+ span_processor,
113
+ supported_signals,
114
+ initial_sampler_config,
115
+ initial_remote_config,
116
+ )
108
117
  if odigos_sampler is not None:
109
118
  client.sampler = odigos_sampler
110
119
 
@@ -119,16 +128,22 @@ def initialize_components(trace_exporters = False, span_processor = None):
119
128
  # Make sure the distro modules are reloaded even if an exception is raised.
120
129
  reload_distro_modules()
121
130
 
122
- def initialize_traces_if_enabled(trace_exporters, resource, span_processor = None, signals = None, initial_sampler_config = None):
131
+
132
+ def initialize_traces_if_enabled(
133
+ trace_exporters,
134
+ resource,
135
+ span_processor=None,
136
+ signals=None,
137
+ initial_sampler_config=None,
138
+ initial_remote_config=None,
139
+ ):
123
140
  global _odigos_sampler
124
141
  # In case collectors signals support receiving traces, initialize the tracer provider with the exporters
125
142
  traces_enabled = signals.get("traceSignal", False)
126
143
 
127
144
  if traces_enabled:
128
-
129
145
  odigos_sampler = OdigosSampler()
130
146
  _odigos_sampler = odigos_sampler
131
- sampler = ParentBased(odigos_sampler)
132
147
 
133
148
  # Apply initial sampler config if provided (from OpAMP first message)
134
149
  if initial_sampler_config is not None:
@@ -136,7 +151,7 @@ def initialize_traces_if_enabled(trace_exporters, resource, span_processor = Non
136
151
 
137
152
  # Exporting using exporters
138
153
  if trace_exporters:
139
- provider = TracerProvider(resource=resource, sampler=sampler)
154
+ provider = TracerProvider(resource=resource, sampler=odigos_sampler)
140
155
  id_generator_name = sdk_config._get_id_generator()
141
156
  id_generator = sdk_config._import_id_generator(id_generator_name)
142
157
  provider.id_generator = id_generator
@@ -147,21 +162,26 @@ def initialize_traces_if_enabled(trace_exporters, resource, span_processor = Non
147
162
 
148
163
  for _, exporter_class in exporters.items():
149
164
  exporter_args = {}
150
- provider.add_span_processor(
151
- BatchSpanProcessor(exporter_class(**exporter_args))
152
- )
165
+ provider.add_span_processor(BatchSpanProcessor(exporter_class(**exporter_args)))
153
166
 
154
167
  # Exporting using EBPF
155
168
  else:
156
- provider = TracerProvider(resource=resource, sampler=sampler)
169
+ provider = TracerProvider(resource=resource, sampler=odigos_sampler)
157
170
  set_tracer_provider(provider)
158
171
  if span_processor is not None:
159
172
  provider.add_span_processor(span_processor)
160
173
 
174
+ # Bootstrap processor config from the first OpAMP message.
175
+ # The update_conf_cb fires before the TracerProvider exists,
176
+ # so the processor misses the initial config; apply it now.
177
+ if initial_remote_config is not None and span_processor is not None and hasattr(span_processor, 'update_config'):
178
+ span_processor.update_config(initial_remote_config)
179
+
161
180
  return odigos_sampler
162
181
 
163
182
  return None
164
183
 
184
+
165
185
  def initialize_metrics_if_enabled(resource, signals):
166
186
  # Currently we're not exporting metrics using the OTEL exporter.
167
187
  metrics_enabled = False
@@ -198,7 +218,6 @@ def start_opamp_client(event):
198
218
 
199
219
  python_version_supported = is_supported_python_version()
200
220
 
201
-
202
221
  client.start(python_version_supported)
203
222
 
204
223
  # Wait for the opamp first message to be received
@@ -230,6 +249,7 @@ def start_opamp_client(event):
230
249
 
231
250
  return client
232
251
 
252
+
233
253
  def update_agent_config(conf: Config):
234
254
  """
235
255
  The function `update_agent_config` checks for a tracer provider and updates its configuration using
@@ -247,27 +267,20 @@ def update_agent_config(conf: Config):
247
267
  if provider is None:
248
268
  return
249
269
 
250
- # Update sampler configuration if sample_config is present
251
- if hasattr(conf, 'sample_config') and conf.sample_config:
252
- # Extract headSampling configuration from traces.headSampling
270
+ # Update sampler configuration if container_config is present
271
+ if hasattr(conf, 'container_config') and conf.container_config:
253
272
  head_sampling_config = None
254
- if 'traces' in conf.sample_config and 'headSampling' in conf.sample_config['traces']:
255
- head_sampling_config = conf.sample_config['traces']['headSampling']
256
-
257
- if head_sampling_config and (
258
- head_sampling_config.get('noisyOperations') is not None
259
- ):
260
- valid_config = head_sampling_config
261
- else:
262
- valid_config = None
273
+ if 'traces' in conf.container_config and 'headSampling' in conf.container_config['traces']:
274
+ head_sampling_config = conf.container_config['traces']['headSampling']
263
275
 
264
276
  # Apply config to sampler - sampler will always exist when this callback is called
265
277
  # because update_agent_config is only called from OpAMP worker thread after sampler creation
266
278
  from opamp import opamp_registry
279
+
267
280
  client = opamp_registry.get_client()
268
281
 
269
282
  if client and hasattr(client, 'sampler') and client.sampler:
270
- client.sampler.update_config(valid_config)
283
+ client.sampler.update_config(head_sampling_config)
271
284
  else:
272
285
  # This should not happen in normal operation
273
286
  pass
@@ -281,19 +294,17 @@ def update_agent_config(conf: Config):
281
294
  # If you attached only one BatchSpanProcessor, _active_span_processor
282
295
  # *is* that object. If you attached several, it is a MultiSpanProcessor
283
296
  # that wraps them in ._span_processors.
284
- processors = (
285
- active_proc._span_processors
286
- if hasattr(active_proc, "_span_processors")
287
- else [active_proc]
288
- )
297
+ processors = active_proc._span_processors if hasattr(active_proc, "_span_processors") else [active_proc]
289
298
 
290
299
  for proc in processors:
291
300
  if hasattr(proc, "update_config"):
292
301
  proc.update_config(conf)
293
302
 
303
+
294
304
  def is_supported_python_version():
295
305
  return sys.version_info >= MINIMUM_PYTHON_SUPPORTED_VERSION
296
306
 
307
+
297
308
  def handle_instrumenation_of_sub_processes():
298
309
  # Resolves a path management issue introduced by OpenTelemetry's sitecustomize implementation.
299
310
  # OpenTelemetry removed auto_instrumentation from the path to address a specific bug
@@ -6,7 +6,7 @@ from opentelemetry.context import Context
6
6
  from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult, _get_parent_trace_state
7
7
  from opentelemetry.semconv.attributes import http_attributes as http_attributes_semconv
8
8
  from opentelemetry.semconv.attributes import server_attributes as server_attributes_semconv
9
- from opentelemetry.trace import Link, SpanKind, TraceState
9
+ from opentelemetry.trace import Link, SpanKind, TraceState, get_current_span
10
10
  from opentelemetry.util.types import Attributes
11
11
 
12
12
  from initializer.schemas.sampling import (
@@ -30,6 +30,11 @@ class OdigosSampler(Sampler):
30
30
  self._lock = Lock()
31
31
  self._config: Optional[HeadSamplingConfig] = None
32
32
 
33
+ def _not_sampled_decision(self) -> Decision:
34
+ if self._config and self._config.get('spanMetricsMode') == "all-spans":
35
+ return Decision.RECORD_ONLY
36
+ return Decision.DROP
37
+
33
38
  def _trace_id_based_sampling(self, trace_id: int, percentage_to_sample: float) -> bool:
34
39
  """Mimic OpenTelemetry's trace ID-based sampling logic with 64-bit range."""
35
40
  # Since percentage to sample is in percents and we need a fraction, divide by 100
@@ -52,6 +57,17 @@ class OdigosSampler(Sampler):
52
57
  ) -> "SamplingResult":
53
58
  attributes = attributes or {}
54
59
  with self._lock:
60
+ parent_span_context = get_current_span(parent_context).get_span_context()
61
+
62
+ # Parent-based logic taken from upstream ParentBased sampler so we can
63
+ # decide to DROP or RECORD_ONLY based on the recordMode configuration.
64
+ # Remote/local parent distinction is removed because both paths used the
65
+ # same not-sampled decision.
66
+ if parent_span_context is not None and parent_span_context.is_valid:
67
+ if parent_span_context.trace_flags.sampled:
68
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
69
+ else:
70
+ return SamplingResult(self._not_sampled_decision(), trace_state=_get_parent_trace_state(parent_context))
55
71
  # sampler_logger.debug(f'Running Should_sample a span with the following attributes: {attributes}')
56
72
 
57
73
  if self._config is None:
@@ -109,7 +125,7 @@ class OdigosSampler(Sampler):
109
125
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=trace_state)
110
126
  else:
111
127
  # sampler_logger.debug(f'Trace [{trace_id}] is dropped with lowest percentage {lowest_percentage}')
112
- return SamplingResult(Decision.DROP, trace_state=trace_state)
128
+ return SamplingResult(self._not_sampled_decision(), trace_state=trace_state)
113
129
 
114
130
  # sampler_logger.debug('No noisy operation matched, sampling the trace')
115
131
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
@@ -199,7 +215,6 @@ class OdigosSampler(Sampler):
199
215
 
200
216
  def update_config(self, new_config):
201
217
  with self._lock:
202
- # sampler_logger.debug(f'Updating the configuration with the new configuration: {new_config}')
203
218
  self._config = new_config
204
219
 
205
220
  def build_tracestate(
@@ -36,4 +36,5 @@ class NoisyOperation(TypedDict, total=False):
36
36
 
37
37
  class HeadSamplingConfig(TypedDict, total=False):
38
38
  dryRun: bool
39
+ spanMetricsMode: str
39
40
  noisyOperations: list[NoisyOperation]
@@ -0,0 +1 @@
1
+ VERSION = "1.0.83"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.81
3
+ Version: 1.0.83
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author-email: odigos-io <support@odigos.io>
6
6
  Requires-Python: >=3.9
@@ -1,38 +1,45 @@
1
1
  import re
2
2
  from dataclasses import dataclass, fields, field
3
- from typing import Any, Dict, Type, TypeVar, get_type_hints
3
+ from typing import Any, Dict, Optional, Type, TypeVar, get_type_hints
4
4
 
5
5
  T = TypeVar("T")
6
6
 
7
+
7
8
  ### Configuration structs ###
8
9
  class DefaultNoneMixin:
9
- #__getattr__ is only invoked when the normal attribute lookup (__dict__, class attributes, etc.) fails.
10
+ # __getattr__ is only invoked when the normal attribute lookup (__dict__, class attributes, etc.) fails.
10
11
  # Prevent raising a ValueError when accessing a non existing key
11
12
  # It'll save us multiple try catch blocks in the future
12
13
  def __getattr__(self, name: str):
13
14
  return None
14
15
 
16
+
15
17
  @dataclass
16
18
  class CodeAttributes(DefaultNoneMixin):
17
- column: bool = False
18
- file_path: bool = False
19
- function: bool = False
19
+ column: bool = False
20
+ file_path: bool = False
21
+ function: bool = False
20
22
  line_number: bool = False
21
- namespace: bool = False
23
+ namespace: bool = False
22
24
  stack_trace: bool = False
23
25
 
26
+
24
27
  @dataclass
25
28
  class Config(DefaultNoneMixin):
26
29
  code_attributes: CodeAttributes = field(default_factory=CodeAttributes)
27
- sample_config: Dict[str, Any] = field(default_factory=dict)
30
+ container_config: Dict[str, Any] = field(default_factory=dict)
31
+ span_metrics_mode: Optional[str] = None
32
+
28
33
 
29
34
  ### Helpers ###
30
35
 
36
+
31
37
  def camel_to_snake(name: str) -> str:
32
38
  """Convert camelCase or PascalCase to snake_case."""
33
39
  s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
34
40
  return s1.lower()
35
41
 
42
+
36
43
  def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
37
44
  """
38
45
  Instantiate the dataclass `cls` from `data`:
@@ -23,7 +23,7 @@ from initializer.process_resource import PROCESS_VPID
23
23
  from google.protobuf.json_format import MessageToDict
24
24
  from google.protobuf.message import DecodeError
25
25
 
26
- from opamp.config import from_dict, Config
26
+ from opamp.config import from_dict, Config, CodeAttributes
27
27
 
28
28
  # Setup the logger
29
29
  opamp_logger = logging.getLogger('odigos')
@@ -33,31 +33,32 @@ _debugger_instance = None
33
33
  env_var_mappings = {
34
34
  "ODIGOS_WORKLOAD_NAMESPACE": ResourceAttributes.K8S_NAMESPACE_NAME,
35
35
  "ODIGOS_CONTAINER_NAME": ResourceAttributes.K8S_CONTAINER_NAME,
36
- "ODIGOS_POD_NAME": ResourceAttributes.K8S_POD_NAME
36
+ "ODIGOS_POD_NAME": ResourceAttributes.K8S_POD_NAME,
37
37
  }
38
38
 
39
+
39
40
  class OpAMPHTTPClient:
40
41
  def __init__(self, opamp_connection_event, condition: threading.Condition):
41
42
  self.server_host = os.getenv('ODIGOS_OPAMP_SERVER_HOST')
42
43
  self.server_url = f"http://{self.server_host}/v1/opamp"
43
- self.signals = {}
44
+ self.container_config = {}
44
45
  self.running = True
45
46
  self.condition = condition
46
47
  self.opamp_connection_event = opamp_connection_event
47
48
  self.next_sequence_num = 0
48
49
  self.instance_uid = uuid7().__str__()
49
50
  self.remote_config_status = None
50
- self.sampler = None # OdigosSampler instance
51
+ self.sampler = None # OdigosSampler instance
51
52
  self.pid = os.getpid()
52
- self.update_conf_cb = None # Callback for configuration updates in the processor
53
- self._initial_sampler_config = None # Store initial sampler config for direct access
53
+ self.update_conf_cb = None # Callback for configuration updates in the processor
54
+ self._initial_sampler_config = None # Store initial sampler config for direct access
55
+ self._initial_remote_config = None # Store initial full Config for processor bootstrap
54
56
 
55
57
  def __repr__(self):
56
58
  return f"<OpAMPHTTPClient instance_uid={self.instance_uid} pid={self.pid}>"
57
59
 
58
60
  def start(self, python_version_supported: bool = None):
59
61
  if not python_version_supported:
60
-
61
62
  python_version = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
62
63
  error_message = f"Opentelemetry SDK require Python in version 3.8 or higher [{python_version} is not supported]"
63
64
 
@@ -101,7 +102,11 @@ class OpAMPHTTPClient:
101
102
  agent_disconnect = self.get_agent_disconnect()
102
103
  agent_failure_message.agent_disconnect.CopyFrom(agent_disconnect)
103
104
 
104
- agent_health = self.get_agent_health(component_health=component_health, last_error=error_message, status=AgentHealthStatus.AGENT_FAILURE.value)
105
+ agent_health = self.get_agent_health(
106
+ component_health=component_health,
107
+ last_error=error_message,
108
+ status=AgentHealthStatus.AGENT_FAILURE.value,
109
+ )
105
110
  agent_failure_message.health.CopyFrom(agent_health)
106
111
 
107
112
  return agent_failure_message
@@ -116,7 +121,11 @@ class OpAMPHTTPClient:
116
121
  agent_disconnect = self.get_agent_disconnect()
117
122
  first_disconnect_message.agent_disconnect.CopyFrom(agent_disconnect)
118
123
 
119
- agent_health = self.get_agent_health(component_health=False, last_error=error_message, status=AgentHealthStatus.UNSUPPORTED_RUNTIME_VERSION.value)
124
+ agent_health = self.get_agent_health(
125
+ component_health=False,
126
+ last_error=error_message,
127
+ status=AgentHealthStatus.UNSUPPORTED_RUNTIME_VERSION.value,
128
+ )
120
129
  first_disconnect_message.health.CopyFrom(agent_health)
121
130
 
122
131
  self.send_agent_to_server_message(first_disconnect_message)
@@ -127,9 +136,19 @@ class OpAMPHTTPClient:
127
136
  for attempt in range(1, max_retries + 1):
128
137
  try:
129
138
  # Send first message to OpAMP server, Health is false as the component is not initialized
130
- agent_health = self.get_agent_health(component_health=False, last_error="Connecting to Odigos configuration server", status=AgentHealthStatus.STARTING.value)
139
+ agent_health = self.get_agent_health(
140
+ component_health=False,
141
+ last_error="Connecting to Odigos configuration server",
142
+ status=AgentHealthStatus.STARTING.value,
143
+ )
131
144
  agent_description = self.get_agent_description()
132
- first_message_server_to_agent = self.send_agent_to_server_message(opamp_pb2.AgentToServer(agent_description=agent_description, health=agent_health, remote_config_status=opamp_pb2.RemoteConfigStatus()))
145
+ first_message_server_to_agent = self.send_agent_to_server_message(
146
+ opamp_pb2.AgentToServer(
147
+ agent_description=agent_description,
148
+ health=agent_health,
149
+ remote_config_status=opamp_pb2.RemoteConfigStatus(),
150
+ )
151
+ )
133
152
 
134
153
  # Check if the response of the first message is empty
135
154
  # It may happen if OpAMPServer is not available
@@ -147,12 +166,12 @@ class OpAMPHTTPClient:
147
166
  # The default config is preloaded when the EBPFSpanProcessor is initialized
148
167
  pass
149
168
 
150
- container_config = utils.get_container_config(first_message_server_to_agent.remote_config.config.config_map)
151
- self.signals = utils.parse_first_message_signals(container_config)
169
+ self.container_config = utils.get_container_config(first_message_server_to_agent.remote_config.config.config_map)
152
170
 
153
- # Store initial sampler config for direct access
171
+ # Store initial configs for direct access (TracerProvider doesn't exist yet)
154
172
  remote_config = self.get_remote_config(first_message_server_to_agent)
155
173
  self._initial_sampler_config = self._extract_sampler_config(remote_config)
174
+ self._initial_remote_config = remote_config
156
175
 
157
176
  # Send healthy message to OpAMP server
158
177
  agent_health = self.get_agent_health(component_health=True, status=AgentHealthStatus.HEALTHY.value)
@@ -164,8 +183,7 @@ class OpAMPHTTPClient:
164
183
  self.opamp_connection_event.error = False
165
184
  return
166
185
 
167
- except Exception as e:
168
- # opamp_logger.error(f"Attempt {attempt}/{max_retries} failed. Error sending full state to OpAMP server: {e}")
186
+ except Exception:
169
187
  pass
170
188
 
171
189
  if attempt < max_retries:
@@ -179,10 +197,19 @@ class OpAMPHTTPClient:
179
197
 
180
198
  # If OpAMP server is throttling, it might not respond in the OpAMP client timeout period (5s).
181
199
  # In this case, we're trying to send a message to the OpAMP server to indicate that OpAMP is not available.
182
- agent_health = self.get_agent_health(component_health=False, last_error="Failed to connect to Odigos configuration server - dynamic configuration unavailable",
183
- status=AgentHealthStatus.NO_CONNECTION_TO_OPAMP_SERVER.value)
200
+ agent_health = self.get_agent_health(
201
+ component_health=False,
202
+ last_error="Failed to connect to Odigos configuration server - dynamic configuration unavailable",
203
+ status=AgentHealthStatus.NO_CONNECTION_TO_OPAMP_SERVER.value,
204
+ )
184
205
  agent_description = self.get_agent_description()
185
- self.send_agent_to_server_message(opamp_pb2.AgentToServer(agent_description=agent_description, health=agent_health, remote_config_status=opamp_pb2.RemoteConfigStatus()))
206
+ self.send_agent_to_server_message(
207
+ opamp_pb2.AgentToServer(
208
+ agent_description=agent_description,
209
+ health=agent_health,
210
+ remote_config_status=opamp_pb2.RemoteConfigStatus(),
211
+ )
212
+ )
186
213
 
187
214
  def worker(self):
188
215
  while self.running:
@@ -218,8 +245,7 @@ class OpAMPHTTPClient:
218
245
  # The default config is preloaded when the EBPFSpanProcessor is initialized
219
246
  pass
220
247
 
221
- except requests_odigos.RequestException as e:
222
- # opamp_logger.error(f"Error fetching data: {e}")
248
+ except requests_odigos.RequestException:
223
249
  pass
224
250
  self.condition.wait(30)
225
251
 
@@ -231,16 +257,9 @@ class OpAMPHTTPClient:
231
257
  component_health_map = {}
232
258
 
233
259
  for lib_name, lib_details in instrumented_libraries.items():
234
- component_health_map[lib_name] = opamp_pb2.ComponentHealth(
235
- healthy=True,
236
- status=json.dumps(lib_details)
237
- )
260
+ component_health_map[lib_name] = opamp_pb2.ComponentHealth(healthy=True, status=json.dumps(lib_details))
238
261
 
239
- health_msg = opamp_pb2.ComponentHealth(
240
- healthy=True,
241
- status="instrumentation libraries report",
242
- component_health_map=component_health_map
243
- )
262
+ health_msg = opamp_pb2.ComponentHealth(healthy=True, status="instrumentation libraries report", component_health_map=component_health_map)
244
263
 
245
264
  self.send_agent_to_server_message(opamp_pb2.AgentToServer(health=health_msg))
246
265
 
@@ -284,29 +303,34 @@ class OpAMPHTTPClient:
284
303
  # If not JSON, just keep it as raw bytes
285
304
  decoded_map[key] = body_bytes
286
305
 
287
- inner = decoded_map.get("", None)
288
- if inner is None:
289
- config = Config() # Return default values for config
290
- else:
291
- config = from_dict(Config, inner)
306
+ config = Config()
292
307
 
293
308
  # Extract container_config if present
294
- sample_config = decoded_map.get("container_config", None)
295
- if sample_config is not None:
296
- config.sample_config = sample_config
309
+ container_config = decoded_map.get("container_config", None)
310
+ if container_config is not None:
311
+ config.container_config = container_config
312
+
313
+ traces = container_config.get('traces', {})
314
+
315
+ span_metrics_mode = traces.get('headSampling', {}).get('spanMetricsMode')
316
+ if span_metrics_mode is not None:
317
+ config.span_metrics_mode = span_metrics_mode
318
+
319
+ code_attrs_raw = traces.get('codeAttributes')
320
+ if isinstance(code_attrs_raw, dict):
321
+ config.code_attributes = from_dict(CodeAttributes, code_attrs_raw)
297
322
 
298
323
  return config
299
324
 
300
- def send_heartbeat(self) -> opamp_pb2.ServerToAgent: # type: ignore
325
+ def send_heartbeat(self) -> opamp_pb2.ServerToAgent: # type: ignore
301
326
  # opamp_logger.debug("Sending heartbeat to OpAMP server...")
302
327
  try:
303
328
  agent_to_server = opamp_pb2.AgentToServer(remote_config_status=self.remote_config_status)
304
329
  return self.send_agent_to_server_message(agent_to_server)
305
- except requests_odigos.RequestException as e:
306
- # opamp_logger.error(f"Error sending heartbeat to OpAMP server: {e}")
330
+ except requests_odigos.RequestException:
307
331
  pass
308
332
 
309
- def get_agent_description(self) -> opamp_pb2.AgentDescription: # type: ignore
333
+ def get_agent_description(self) -> opamp_pb2.AgentDescription: # type: ignore
310
334
  # The "DISABLE_OPAMP_CLIENT" environment variable is defined only in our VMs environments.
311
335
  # Here we use it exclusively to distinguish between virtual machine and Kubernetes environments.
312
336
  #
@@ -325,42 +349,24 @@ class OpAMPHTTPClient:
325
349
  process_id_key = PROCESS_VPID
326
350
 
327
351
  identifying_attributes = [
328
- anyvalue_pb2.KeyValue(
329
- key=ResourceAttributes.SERVICE_INSTANCE_ID,
330
- value=anyvalue_pb2.AnyValue(string_value=self.instance_uid)
331
- ),
332
- anyvalue_pb2.KeyValue(
333
- key=process_id_key,
334
- value=anyvalue_pb2.AnyValue(int_value=self.pid)
335
- ),
336
- anyvalue_pb2.KeyValue(
337
- key=ResourceAttributes.TELEMETRY_SDK_LANGUAGE,
338
- value=anyvalue_pb2.AnyValue(string_value="python")
339
- )
352
+ anyvalue_pb2.KeyValue(key=ResourceAttributes.SERVICE_INSTANCE_ID, value=anyvalue_pb2.AnyValue(string_value=self.instance_uid)),
353
+ anyvalue_pb2.KeyValue(key=process_id_key, value=anyvalue_pb2.AnyValue(int_value=self.pid)),
354
+ anyvalue_pb2.KeyValue(key=ResourceAttributes.TELEMETRY_SDK_LANGUAGE, value=anyvalue_pb2.AnyValue(string_value="python")),
340
355
  ]
341
356
 
342
357
  # Add additional attributes from environment variables
343
358
  for env_var, attribute_key in env_var_mappings.items():
344
359
  value = os.environ.get(env_var)
345
360
  if value:
346
- identifying_attributes.append(
347
- anyvalue_pb2.KeyValue(
348
- key=attribute_key,
349
- value=anyvalue_pb2.AnyValue(string_value=value)
350
- )
351
- )
361
+ identifying_attributes.append(anyvalue_pb2.KeyValue(key=attribute_key, value=anyvalue_pb2.AnyValue(string_value=value)))
352
362
 
353
- return opamp_pb2.AgentDescription(
354
- identifying_attributes=identifying_attributes,
355
- non_identifying_attributes=[]
356
- )
363
+ return opamp_pb2.AgentDescription(identifying_attributes=identifying_attributes, non_identifying_attributes=[])
357
364
 
358
- def get_agent_disconnect(self) -> opamp_pb2.AgentDisconnect: # type: ignore
365
+ def get_agent_disconnect(self) -> opamp_pb2.AgentDisconnect: # type: ignore
359
366
  return opamp_pb2.AgentDisconnect()
360
367
 
361
- def get_agent_health(self, component_health: bool = None, last_error : str = None, status: str = None) -> opamp_pb2.ComponentHealth: # type: ignore
362
- health = opamp_pb2.ComponentHealth(
363
- )
368
+ def get_agent_health(self, component_health: bool = None, last_error: str = None, status: str = None) -> opamp_pb2.ComponentHealth: # type: ignore
369
+ health = opamp_pb2.ComponentHealth()
364
370
  if component_health is not None:
365
371
  health.healthy = component_health
366
372
  if last_error is not None:
@@ -370,8 +376,7 @@ class OpAMPHTTPClient:
370
376
 
371
377
  return health
372
378
 
373
-
374
- def send_agent_to_server_message(self, message: opamp_pb2.AgentToServer) -> opamp_pb2.ServerToAgent: # type: ignore
379
+ def send_agent_to_server_message(self, message: opamp_pb2.AgentToServer) -> opamp_pb2.ServerToAgent: # type: ignore
375
380
 
376
381
  message.instance_uid = self.instance_uid.encode('utf-8')
377
382
  message.sequence_num = self.next_sequence_num
@@ -417,10 +422,20 @@ class OpAMPHTTPClient:
417
422
  self.running = False
418
423
  # opamp_logger.info("Sending agent disconnect message to OpAMP server...")
419
424
  if custom_failure_message:
420
- disconnect_message = self.get_agent_failure_disconnect_message(error_message=custom_failure_message, component_health=component_health)
425
+ disconnect_message = self.get_agent_failure_disconnect_message(
426
+ error_message=custom_failure_message,
427
+ component_health=component_health,
428
+ )
421
429
  else:
422
- agent_health = self.get_agent_health(component_health=component_health, last_error="Python runtime is exiting", status=AgentHealthStatus.TERMINATED.value)
423
- disconnect_message = opamp_pb2.AgentToServer(agent_disconnect=opamp_pb2.AgentDisconnect(), health=agent_health)
430
+ agent_health = self.get_agent_health(
431
+ component_health=component_health,
432
+ last_error="Python runtime is exiting",
433
+ status=AgentHealthStatus.TERMINATED.value,
434
+ )
435
+ disconnect_message = opamp_pb2.AgentToServer(
436
+ agent_disconnect=opamp_pb2.AgentDisconnect(),
437
+ health=agent_health,
438
+ )
424
439
 
425
440
  with self.condition:
426
441
  self.condition.notify_all()
@@ -428,7 +443,7 @@ class OpAMPHTTPClient:
428
443
 
429
444
  self.send_agent_to_server_message(disconnect_message)
430
445
 
431
- def update_remote_config_status(self, server_to_agent: opamp_pb2.ServerToAgent) -> bool: # type: ignore
446
+ def update_remote_config_status(self, server_to_agent: opamp_pb2.ServerToAgent) -> bool: # type: ignore
432
447
  if server_to_agent.HasField("remote_config"):
433
448
  remote_config_hash = server_to_agent.remote_config.config_hash
434
449
  remote_config_status = opamp_pb2.RemoteConfigStatus(last_remote_config_hash=remote_config_hash)
@@ -441,27 +456,23 @@ class OpAMPHTTPClient:
441
456
 
442
457
  def _extract_sampler_config(self, remote_config):
443
458
  """Extract sampler config from remote config, same logic as update_agent_config"""
444
- if not hasattr(remote_config, 'sample_config') or not remote_config.sample_config:
459
+ if not hasattr(remote_config, 'container_config') or not remote_config.container_config:
445
460
  return None
446
461
 
447
462
  # Extract headSampling configuration from traces.headSampling
448
- if 'traces' not in remote_config.sample_config or 'headSampling' not in remote_config.sample_config['traces']:
463
+ if 'traces' not in remote_config.container_config or 'headSampling' not in remote_config.container_config['traces']:
449
464
  return None
450
465
 
451
- head_sampling_config = remote_config.sample_config['traces']['headSampling']
452
-
453
- # Check if we have valid sampling config (same validation as update_agent_config)
454
- if head_sampling_config and (
455
- head_sampling_config.get('noisyOperations') is not None
456
- ):
457
- return head_sampling_config
458
- else:
459
- return None
466
+ return remote_config.container_config['traces']['headSampling'] or None
460
467
 
461
468
  def get_initial_sampler_config(self):
462
469
  """Get the sampler config from the initial OpAMP message"""
463
470
  return self._initial_sampler_config
464
471
 
472
+ def get_initial_remote_config(self):
473
+ """Get the full Config from the initial OpAMP message"""
474
+ return self._initial_remote_config
475
+
465
476
 
466
477
  # Mock client class for non-OpAMP installations
467
478
  # This class simulates the OpAMP client when the OpAMP server is not available.
@@ -469,7 +480,7 @@ class OpAMPHTTPClient:
469
480
  class MockOpAMPClient:
470
481
  def __init__(self, opamp_connection_event, *args, **kwargs):
471
482
  self.pid = os.getpid()
472
- self.signals = {'traceSignal': True}
483
+ self.container_config = {"traces": {}}
473
484
  self.sampler = None # For compatibility
474
485
  opamp_connection_event.event.set()
475
486
 
@@ -477,5 +488,9 @@ class MockOpAMPClient:
477
488
  """Mock client has no sampler config"""
478
489
  return None
479
490
 
491
+ def get_initial_remote_config(self):
492
+ """Mock client has no remote config"""
493
+ return None
494
+
480
495
  def shutdown(self, custom_failure_message=None):
481
496
  pass
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "odigos-opentelemetry-python"
7
- version = "1.0.81"
7
+ version = "1.0.83"
8
8
  description = "Odigos Initializer for Python OpenTelemetry Components"
9
9
  requires-python = ">=3.9"
10
10
  authors = [
@@ -145,6 +145,9 @@ extend-exclude = [
145
145
  # Default rule set: pycodestyle errors (E) + pyflakes (F).
146
146
  select = ["E", "F"]
147
147
 
148
+ [tool.ruff.lint.per-file-ignores]
149
+ "initializer/components.py" = ["E402"]
150
+
148
151
  [tool.ruff.format]
149
152
  quote-style = "preserve"
150
153
  indent-style = "space"
@@ -1 +0,0 @@
1
- VERSION = "1.0.81"