odigos-opentelemetry-python 1.0.80__tar.gz → 1.0.82__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 (35) hide show
  1. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/PKG-INFO +1 -1
  2. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/components.py +47 -35
  3. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/odigos_sampler.py +107 -36
  4. odigos_opentelemetry_python-1.0.82/initializer/schemas/sampling.py +40 -0
  5. odigos_opentelemetry_python-1.0.82/initializer/version.py +1 -0
  6. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/PKG-INFO +1 -1
  7. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/SOURCES.txt +2 -0
  8. odigos_opentelemetry_python-1.0.82/opamp/__init__.py +0 -0
  9. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/config.py +13 -6
  10. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/http_client.py +90 -75
  11. odigos_opentelemetry_python-1.0.82/opamp/utils.py +26 -0
  12. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/pyproject.toml +41 -1
  13. odigos_opentelemetry_python-1.0.80/initializer/version.py +0 -1
  14. odigos_opentelemetry_python-1.0.80/opamp/utils.py +0 -26
  15. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/LICENSE +0 -0
  16. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/README.md +0 -0
  17. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/__init__.py +0 -0
  18. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/distro/__init__.py +0 -0
  19. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/distro/distro.py +0 -0
  20. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/distro/instrumentation_registry.py +0 -0
  21. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/envs.py +0 -0
  22. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/exit_hook.py +0 -0
  23. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/lib_handling.py +0 -0
  24. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/post_instrument.py +0 -0
  25. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/initializer/process_resource.py +0 -0
  26. {odigos_opentelemetry_python-1.0.80/opamp → odigos_opentelemetry_python-1.0.82/initializer/schemas}/__init__.py +0 -0
  27. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/dependency_links.txt +0 -0
  28. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/entry_points.txt +0 -0
  29. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/requires.txt +0 -0
  30. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/odigos_opentelemetry_python.egg-info/top_level.txt +0 -0
  31. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/anyvalue_pb2.py +0 -0
  32. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/health_status.py +0 -0
  33. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/opamp_pb2.py +0 -0
  34. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/opamp/opamp_registry.py +0 -0
  35. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.82}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.80
3
+ Version: 1.0.82
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
@@ -254,20 +274,14 @@ def update_agent_config(conf: Config):
254
274
  if 'traces' in conf.sample_config and 'headSampling' in conf.sample_config['traces']:
255
275
  head_sampling_config = conf.sample_config['traces']['headSampling']
256
276
 
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
263
-
264
277
  # Apply config to sampler - sampler will always exist when this callback is called
265
278
  # because update_agent_config is only called from OpAMP worker thread after sampler creation
266
279
  from opamp import opamp_registry
280
+
267
281
  client = opamp_registry.get_client()
268
282
 
269
283
  if client and hasattr(client, 'sampler') and client.sampler:
270
- client.sampler.update_config(valid_config)
284
+ client.sampler.update_config(head_sampling_config)
271
285
  else:
272
286
  # This should not happen in normal operation
273
287
  pass
@@ -281,19 +295,17 @@ def update_agent_config(conf: Config):
281
295
  # If you attached only one BatchSpanProcessor, _active_span_processor
282
296
  # *is* that object. If you attached several, it is a MultiSpanProcessor
283
297
  # 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
- )
298
+ processors = active_proc._span_processors if hasattr(active_proc, "_span_processors") else [active_proc]
289
299
 
290
300
  for proc in processors:
291
301
  if hasattr(proc, "update_config"):
292
302
  proc.update_config(conf)
293
303
 
304
+
294
305
  def is_supported_python_version():
295
306
  return sys.version_info >= MINIMUM_PYTHON_SUPPORTED_VERSION
296
307
 
308
+
297
309
  def handle_instrumenation_of_sub_processes():
298
310
  # Resolves a path management issue introduced by OpenTelemetry's sitecustomize implementation.
299
311
  # OpenTelemetry removed auto_instrumentation from the path to address a specific bug
@@ -1,85 +1,137 @@
1
+ import logging
2
+ from threading import Lock
3
+ from typing import Any, Mapping, Optional, Sequence
4
+
5
+ from opentelemetry.context import Context
6
+ from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult, _get_parent_trace_state
1
7
  from opentelemetry.semconv.attributes import http_attributes as http_attributes_semconv
2
8
  from opentelemetry.semconv.attributes import server_attributes as server_attributes_semconv
3
- from opentelemetry.sdk.trace.sampling import Sampler, Decision, SamplingResult, _get_parent_trace_state
4
- from threading import Lock
5
- import logging
9
+ from opentelemetry.trace import Link, SpanKind, TraceState, get_current_span
10
+ from opentelemetry.util.types import Attributes
6
11
 
12
+ from initializer.schemas.sampling import (
13
+ HeadSamplingConfig,
14
+ HeadSamplingHttpClientOperationMatcher,
15
+ HeadSamplingHttpServerOperationMatcher,
16
+ HeadSamplingOperationMatcher,
17
+ NoisyOperation,
18
+ )
7
19
 
8
20
  # Setup the Sampler logger
9
21
  sampler_logger = logging.getLogger('odigos')
10
22
 
11
- class OdigosSampler(Sampler):
12
23
 
24
+ class OdigosSampler(Sampler):
13
25
  # For compatibility with 64 bit trace IDs, the sampler checks the 64
14
26
  # low-order bits of the trace ID to decide whether to sample a given trace.
15
27
  TRACE_ID_LIMIT = (1 << 64) - 1
16
28
 
17
29
  def __init__(self):
18
30
  self._lock = Lock()
19
- self._config = None
31
+ self._config: Optional[HeadSamplingConfig] = None
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
20
37
 
21
38
  def _trace_id_based_sampling(self, trace_id: int, percentage_to_sample: float) -> bool:
22
39
  """Mimic OpenTelemetry's trace ID-based sampling logic with 64-bit range."""
23
40
  # Since percentage to sample is in percents and we need a fraction, divide by 100
24
41
  fraction = percentage_to_sample / 100
25
-
42
+
26
43
  # Calculate the bound based on the fraction using OpenTelemetry's approach
27
44
  bound = round(fraction * (self.TRACE_ID_LIMIT + 1))
28
45
  # Apply the TRACE_ID_LIMIT mask to ensure 64-bit range and compare it to the bound
29
46
  return (trace_id & self.TRACE_ID_LIMIT) < bound
30
47
 
31
- def should_sample(self, parent_context, trace_id, name, kind, attributes, links):
48
+ def should_sample(
49
+ self,
50
+ parent_context: Optional[Context],
51
+ trace_id: int,
52
+ name: str,
53
+ kind: Optional[SpanKind] = None,
54
+ attributes: Attributes = None,
55
+ links: Optional[Sequence["Link"]] = None,
56
+ trace_state: Optional[TraceState] = None,
57
+ ) -> "SamplingResult":
32
58
  attributes = attributes or {}
33
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))
34
71
  # sampler_logger.debug(f'Running Should_sample a span with the following attributes: {attributes}')
35
72
 
36
73
  if self._config is None:
37
74
  # sampler_logger.debug('No configuration is set, returning RECORD_AND_SAMPLE')
38
75
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
39
76
 
40
- noisy_operations = self._config.get('noisyOperations', [])
41
- lowest_percentage = None
77
+ is_dry_run: bool = self._config.get('dryRun', False)
78
+ noisy_operations: list[NoisyOperation] = self._config.get('noisyOperations', [])
79
+ # Track the rule with the lowest percentageAtMost so the tracestate entry reports the actual deciding rule's id (first-wins on ties)
80
+ winning_operation: Optional[NoisyOperation] = None
42
81
 
43
82
  for noisy_operation in noisy_operations:
44
83
  if noisy_operation.get("disabled"):
45
84
  continue
46
85
 
47
- percentage_to_sample = noisy_operation.get("percentageAtMost", 0.0)
86
+ percentage_to_sample: float = noisy_operation.get("percentageAtMost", 0.0)
48
87
 
49
- http_operation_sample = noisy_operation.get("operation")
50
- if not http_operation_sample:
51
- continue
88
+ http_operation_sample: Optional[HeadSamplingOperationMatcher] = noisy_operation.get("operation")
52
89
 
53
90
  # sampler_logger.debug(f'Evaluating noisy operation: {noisy_operation}')
54
91
 
55
- matched = False
56
-
57
- if http_server_sample := http_operation_sample.get("httpServer"):
58
- matched = self._match_http_server_sample_rule(http_server_sample, attributes) or matched
92
+ # No operation matcher -> rule applies workload-wide (matches every span)
93
+ # This supports Sampling rules that scope by sourceScopes (workload) without specific HTTP matcher
94
+ if not http_operation_sample:
95
+ matched = True
96
+ else: # When we have a granular operation, check it vs the server/client rules specified in the noisy operation
97
+ matched = False
98
+ if http_server_sample := http_operation_sample.get("httpServer"):
99
+ matched = self._match_http_server_sample_rule(http_server_sample, attributes) or matched
59
100
 
60
- if http_client_sample := http_operation_sample.get("httpClient"):
61
- matched = self._match_http_client_sample_rule(http_client_sample, attributes) or matched
101
+ if http_client_sample := http_operation_sample.get("httpClient"):
102
+ matched = self._match_http_client_sample_rule(http_client_sample, attributes) or matched
62
103
 
63
104
  # sampler_logger.debug(f'Noisy operation matched: {matched}, percentage: {percentage_to_sample}')
64
- if matched: # For matched operations, compute the minimal lowest percentage
65
- if lowest_percentage is None:
66
- lowest_percentage = percentage_to_sample
67
- else:
68
- lowest_percentage = min(lowest_percentage, percentage_to_sample)
69
-
70
- if lowest_percentage is not None:
71
- if self._trace_id_based_sampling(trace_id, lowest_percentage):
105
+ if matched and (winning_operation is None or percentage_to_sample < winning_operation.get("percentageAtMost", 0.0)):
106
+ winning_operation = noisy_operation
107
+
108
+ if winning_operation is not None:
109
+ # Build tracestate from the winning rule and attach it to both decisions.
110
+ # Even on DROP the SpanContext (and its trace_state) is propagated to downstream services via the tracestate header,
111
+ # enabling consistent head-sampling and tail-sampler decisions across the trace.
112
+ lowest_percentage = winning_operation.get("percentageAtMost", 0.0)
113
+ sampled = self._trace_id_based_sampling(trace_id, lowest_percentage)
114
+
115
+ dry_run_sampled = sampled if is_dry_run else None
116
+ trace_state = self.build_tracestate(parent_context, winning_operation, dry_run_sampled)
117
+
118
+ # In dry-run mode, never actually drop spans
119
+ if is_dry_run:
120
+ # sampler_logger.debug(f'Dry run [{trace_id}] would_be_sampled={sampled} (lowest_percentage={lowest_percentage})')
121
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=trace_state)
122
+
123
+ if sampled:
72
124
  # sampler_logger.debug(f'Trace [{trace_id}] is sampled with lowest percentage {lowest_percentage}')
73
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
125
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=trace_state)
74
126
  else:
75
127
  # sampler_logger.debug(f'Trace [{trace_id}] is dropped with lowest percentage {lowest_percentage}')
76
- return SamplingResult(Decision.DROP)
128
+ return SamplingResult(self._not_sampled_decision(), trace_state=trace_state)
77
129
 
78
- # sampler_logger.debug(f'No noisy operation matched, sampling the trace')
130
+ # sampler_logger.debug('No noisy operation matched, sampling the trace')
79
131
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
80
132
 
81
133
  @staticmethod
82
- def _get_attribute(span_attributes: dict[str, str], *keys: str):
134
+ def _get_attribute(span_attributes: Mapping[str, Any], *keys: str):
83
135
  """Return the first non-None value found for the given attribute keys."""
84
136
  for key in keys:
85
137
  value = span_attributes.get(key)
@@ -87,7 +139,7 @@ class OdigosSampler(Sampler):
87
139
  return value
88
140
  return None
89
141
 
90
- def _match_http_server_sample_rule(self, http_server_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
142
+ def _match_http_server_sample_rule(self, http_server_rule: HeadSamplingHttpServerOperationMatcher, span_attributes: Mapping[str, Any]) -> bool:
91
143
  """
92
144
  Try to match all the http server's attributes to the definitions of the noisy action
93
145
  """
@@ -106,7 +158,7 @@ class OdigosSampler(Sampler):
106
158
  # If we have target, try to match it with the route in the sampling rule
107
159
  if target:
108
160
  return self._match_rule_route_to_span(http_server_rule["route"], target)
109
-
161
+
110
162
  if http_server_rule.get("routePrefix") and not (span_route or "").startswith(http_server_rule["routePrefix"]):
111
163
  return False
112
164
  if http_server_rule.get("method") and http_server_rule["method"] != span_method:
@@ -114,7 +166,7 @@ class OdigosSampler(Sampler):
114
166
 
115
167
  return True
116
168
 
117
- def _match_http_client_sample_rule(self, http_client_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
169
+ def _match_http_client_sample_rule(self, http_client_rule: HeadSamplingHttpClientOperationMatcher, span_attributes: Mapping[str, Any]) -> bool:
118
170
  """
119
171
  Try to match all the http client's attributes to the definitions of the noisy action
120
172
  """
@@ -158,11 +210,30 @@ class OdigosSampler(Sampler):
158
210
 
159
211
  return True
160
212
 
161
-
162
213
  def get_description(self):
163
214
  return "OdigosSampler"
164
215
 
165
216
  def update_config(self, new_config):
166
217
  with self._lock:
167
- # sampler_logger.debug(f'Updating the configuration with the new configuration: {new_config}')
168
218
  self._config = new_config
219
+
220
+ def build_tracestate(
221
+ self,
222
+ parent_context: Optional[Context],
223
+ noisy_operation: NoisyOperation,
224
+ dry_run_sampled: Optional[bool] = None,
225
+ ) -> TraceState:
226
+ tracestate = _get_parent_trace_state(parent_context)
227
+
228
+ if not tracestate:
229
+ tracestate = TraceState()
230
+
231
+ # Round percentage to 2 decimal places for byte-identical tracestate values
232
+ percentage = round(noisy_operation.get("percentageAtMost", 0.0), 2)
233
+ noisy_op_tracestate_value = f"c:n;dr.p:{percentage};dr.id:{noisy_operation['id']}"
234
+
235
+ if dry_run_sampled is not None:
236
+ noisy_op_tracestate_value += ";dry:t" if dry_run_sampled else ";dry:f"
237
+ tracestate = tracestate.add("odigos", noisy_op_tracestate_value)
238
+
239
+ return tracestate
@@ -0,0 +1,40 @@
1
+ """Typed shapes for the head-sampling configuration sent by the Odigos OpAMP server.
2
+
3
+ Mirrors the Go schema in odigos/common/api/sampling (matchers_head.go, sampling.go).
4
+ All fields are `omitempty` on the Go side (except NoisyOperation.id), so every
5
+ key is treated as optional on the wire — hence `total=False` on every TypedDict.
6
+ """
7
+
8
+ from typing import TypedDict
9
+
10
+
11
+ class HeadSamplingHttpServerOperationMatcher(TypedDict, total=False):
12
+ route: str
13
+ routePrefix: str
14
+ method: str
15
+
16
+
17
+ class HeadSamplingHttpClientOperationMatcher(TypedDict, total=False):
18
+ serverAddress: str
19
+ templatedPath: str
20
+ templatedPathPrefix: str
21
+ method: str
22
+
23
+
24
+ class HeadSamplingOperationMatcher(TypedDict, total=False):
25
+ httpServer: HeadSamplingHttpServerOperationMatcher
26
+ httpClient: HeadSamplingHttpClientOperationMatcher
27
+
28
+
29
+ class NoisyOperation(TypedDict, total=False):
30
+ id: str
31
+ name: str
32
+ disabled: bool
33
+ operation: HeadSamplingOperationMatcher
34
+ percentageAtMost: float
35
+
36
+
37
+ class HeadSamplingConfig(TypedDict, total=False):
38
+ dryRun: bool
39
+ spanMetricsMode: str
40
+ noisyOperations: list[NoisyOperation]
@@ -0,0 +1 @@
1
+ VERSION = "1.0.82"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.80
3
+ Version: 1.0.82
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author-email: odigos-io <support@odigos.io>
6
6
  Requires-Python: >=3.9
@@ -13,6 +13,8 @@ initializer/version.py
13
13
  initializer/distro/__init__.py
14
14
  initializer/distro/distro.py
15
15
  initializer/distro/instrumentation_registry.py
16
+ initializer/schemas/__init__.py
17
+ initializer/schemas/sampling.py
16
18
  odigos_opentelemetry_python.egg-info/PKG-INFO
17
19
  odigos_opentelemetry_python.egg-info/SOURCES.txt
18
20
  odigos_opentelemetry_python.egg-info/dependency_links.txt
File without changes
@@ -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
30
  sample_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`:
@@ -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
- sdk_config = utils.get_sdk_config(first_message_server_to_agent.remote_config.config.config_map)
151
- self.signals = utils.parse_first_message_signals(sdk_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)
@@ -165,7 +184,7 @@ class OpAMPHTTPClient:
165
184
  return
166
185
 
167
186
  except Exception as e:
168
- # opamp_logger.error(f"Attempt {attempt}/{max_retries} failed. Error sending full state to OpAMP server: {e}")
187
+ print(f"[send_first_message_with_retry] Error: {e}", flush=True)
169
188
  pass
170
189
 
171
190
  if attempt < max_retries:
@@ -179,10 +198,19 @@ class OpAMPHTTPClient:
179
198
 
180
199
  # If OpAMP server is throttling, it might not respond in the OpAMP client timeout period (5s).
181
200
  # 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)
201
+ agent_health = self.get_agent_health(
202
+ component_health=False,
203
+ last_error="Failed to connect to Odigos configuration server - dynamic configuration unavailable",
204
+ status=AgentHealthStatus.NO_CONNECTION_TO_OPAMP_SERVER.value,
205
+ )
184
206
  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()))
207
+ self.send_agent_to_server_message(
208
+ opamp_pb2.AgentToServer(
209
+ agent_description=agent_description,
210
+ health=agent_health,
211
+ remote_config_status=opamp_pb2.RemoteConfigStatus(),
212
+ )
213
+ )
186
214
 
187
215
  def worker(self):
188
216
  while self.running:
@@ -218,8 +246,7 @@ class OpAMPHTTPClient:
218
246
  # The default config is preloaded when the EBPFSpanProcessor is initialized
219
247
  pass
220
248
 
221
- except requests_odigos.RequestException as e:
222
- # opamp_logger.error(f"Error fetching data: {e}")
249
+ except requests_odigos.RequestException:
223
250
  pass
224
251
  self.condition.wait(30)
225
252
 
@@ -231,16 +258,9 @@ class OpAMPHTTPClient:
231
258
  component_health_map = {}
232
259
 
233
260
  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
- )
261
+ component_health_map[lib_name] = opamp_pb2.ComponentHealth(healthy=True, status=json.dumps(lib_details))
238
262
 
239
- health_msg = opamp_pb2.ComponentHealth(
240
- healthy=True,
241
- status="instrumentation libraries report",
242
- component_health_map=component_health_map
243
- )
263
+ health_msg = opamp_pb2.ComponentHealth(healthy=True, status="instrumentation libraries report", component_health_map=component_health_map)
244
264
 
245
265
  self.send_agent_to_server_message(opamp_pb2.AgentToServer(health=health_msg))
246
266
 
@@ -286,7 +306,7 @@ class OpAMPHTTPClient:
286
306
 
287
307
  inner = decoded_map.get("", None)
288
308
  if inner is None:
289
- config = Config() # Return default values for config
309
+ config = Config() # Return default values for config
290
310
  else:
291
311
  config = from_dict(Config, inner)
292
312
 
@@ -295,18 +315,22 @@ class OpAMPHTTPClient:
295
315
  if sample_config is not None:
296
316
  config.sample_config = sample_config
297
317
 
318
+ try:
319
+ config.span_metrics_mode = sample_config['traces']['headSampling']['spanMetricsMode']
320
+ except (KeyError, TypeError):
321
+ pass
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)
@@ -448,20 +463,16 @@ class OpAMPHTTPClient:
448
463
  if 'traces' not in remote_config.sample_config or 'headSampling' not in remote_config.sample_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.sample_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
@@ -0,0 +1,26 @@
1
+ import json
2
+
3
+ def get_container_config(config_map):
4
+ """
5
+ Extracts and parses the container configuration from the config map.
6
+ """
7
+ if "container_config" not in config_map:
8
+ return {}
9
+
10
+ try:
11
+ return json.loads(config_map["container_config"].body)
12
+ except json.JSONDecodeError:
13
+ return {}
14
+
15
+
16
+ def parse_first_message_signals(container_config): # type: ignore
17
+ """
18
+ Parses the trace, logs, and metrics signals from the container config.
19
+ Signal is considered enabled if its key exists in the config.
20
+ """
21
+
22
+ return {
23
+ "traceSignal": "traces" in container_config,
24
+ "metricsSignal": "metrics" in container_config,
25
+ "logsSignal": "logs" in container_config,
26
+ }
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "odigos-opentelemetry-python"
7
- version = "1.0.80"
7
+ version = "1.0.82"
8
8
  description = "Odigos Initializer for Python OpenTelemetry Components"
9
9
  requires-python = ">=3.9"
10
10
  authors = [
@@ -92,6 +92,13 @@ test = [
92
92
  "pytest >= 8.0",
93
93
  ]
94
94
 
95
+ [dependency-groups]
96
+ dev = [
97
+ "ruff >= 0.15.12",
98
+ "ty >= 0.0.33",
99
+ "pre-commit >= 3.5.0",
100
+ ]
101
+
95
102
  [project.entry-points.opentelemetry_post_instrument]
96
103
  odigos_post = "initializer.post_instrument:run_post_hook"
97
104
 
@@ -122,3 +129,36 @@ include = [
122
129
  "opentelemetry",
123
130
  "opentelemetry.*",
124
131
  ]
132
+
133
+ [tool.ruff]
134
+ line-length = 150
135
+ target-version = "py39"
136
+ extend-exclude = [
137
+ ".venv",
138
+ "dist",
139
+ "build",
140
+ "*.egg-info",
141
+ "tests/integration/configurator",
142
+ ]
143
+
144
+ [tool.ruff.lint]
145
+ # Default rule set: pycodestyle errors (E) + pyflakes (F).
146
+ select = ["E", "F"]
147
+
148
+ [tool.ruff.lint.per-file-ignores]
149
+ "initializer/components.py" = ["E402"]
150
+
151
+ [tool.ruff.format]
152
+ quote-style = "preserve"
153
+ indent-style = "space"
154
+
155
+ [tool.ty.environment]
156
+ python-version = "3.9"
157
+
158
+ [tool.ty.src]
159
+ exclude = [
160
+ ".venv",
161
+ "dist",
162
+ "build",
163
+ "tests/integration/configurator",
164
+ ]
@@ -1 +0,0 @@
1
- VERSION = "1.0.80"
@@ -1,26 +0,0 @@
1
- import json
2
-
3
- def get_sdk_config(config_map):
4
- """
5
- Extracts and parses the SDK configuration from the config map.
6
- """
7
- if "SDK" not in config_map:
8
- return {}
9
-
10
- try:
11
- return json.loads(config_map["SDK"].body)
12
- except json.JSONDecodeError:
13
- return {}
14
-
15
-
16
-
17
- def parse_first_message_signals(sdk_config): # type: ignore
18
- """
19
- Parses the trace, logs, and metrics signals configuration from the SDK config.
20
- """
21
-
22
- return {
23
- "traceSignal": sdk_config.get("traceSignal", {}).get("enabled", False),
24
- "metricsSignal": sdk_config.get("metricsSignal", {}).get("enabled", False),
25
- "logsSignal": sdk_config.get("logsSignal", {}).get("enabled", False),
26
- }