odigos-opentelemetry-python 1.0.78__tar.gz → 1.0.79__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.78 → odigos_opentelemetry_python-1.0.79}/PKG-INFO +1 -1
  2. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/components.py +17 -7
  3. odigos_opentelemetry_python-1.0.79/initializer/odigos_sampler.py +168 -0
  4. odigos_opentelemetry_python-1.0.79/initializer/version.py +1 -0
  5. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/PKG-INFO +1 -1
  6. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/SOURCES.txt +0 -1
  7. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/http_client.py +1 -2
  8. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/pyproject.toml +1 -1
  9. odigos_opentelemetry_python-1.0.78/initializer/odigos_sampler.py +0 -98
  10. odigos_opentelemetry_python-1.0.78/initializer/sampling_operators.py +0 -7
  11. odigos_opentelemetry_python-1.0.78/initializer/version.py +0 -1
  12. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/LICENSE +0 -0
  13. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/README.md +0 -0
  14. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/__init__.py +0 -0
  15. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/distro/__init__.py +0 -0
  16. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/distro/distro.py +0 -0
  17. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/distro/instrumentation_registry.py +0 -0
  18. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/envs.py +0 -0
  19. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/exit_hook.py +0 -0
  20. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/lib_handling.py +0 -0
  21. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/post_instrument.py +0 -0
  22. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/initializer/process_resource.py +0 -0
  23. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/dependency_links.txt +0 -0
  24. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/entry_points.txt +0 -0
  25. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/requires.txt +0 -0
  26. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/odigos_opentelemetry_python.egg-info/top_level.txt +0 -0
  27. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/__init__.py +0 -0
  28. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/anyvalue_pb2.py +0 -0
  29. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/config.py +0 -0
  30. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/health_status.py +0 -0
  31. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/opamp_pb2.py +0 -0
  32. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/opamp_registry.py +0 -0
  33. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/opamp/utils.py +0 -0
  34. {odigos_opentelemetry_python-1.0.78 → odigos_opentelemetry_python-1.0.79}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.78
3
+ Version: 1.0.79
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author-email: odigos-io <support@odigos.io>
6
6
  Requires-Python: >=3.9
@@ -53,6 +53,8 @@ from opamp.http_client import OpAMPHTTPClient, MockOpAMPClient
53
53
 
54
54
  MINIMUM_PYTHON_SUPPORTED_VERSION = (3, 9)
55
55
 
56
+ _odigos_sampler = None
57
+
56
58
 
57
59
  def initialize_components(trace_exporters = False, span_processor = None):
58
60
  # Ensure each child process after fork gets a fresh OpAMP client
@@ -118,12 +120,14 @@ def initialize_components(trace_exporters = False, span_processor = None):
118
120
  reload_distro_modules()
119
121
 
120
122
  def initialize_traces_if_enabled(trace_exporters, resource, span_processor = None, signals = None, initial_sampler_config = None):
123
+ global _odigos_sampler
121
124
  # In case collectors signals support receiving traces, initialize the tracer provider with the exporters
122
125
  traces_enabled = signals.get("traceSignal", False)
123
126
 
124
127
  if traces_enabled:
125
128
 
126
129
  odigos_sampler = OdigosSampler()
130
+ _odigos_sampler = odigos_sampler
127
131
  sampler = ParentBased(odigos_sampler)
128
132
 
129
133
  # Apply initial sampler config if provided (from OpAMP first message)
@@ -250,10 +254,8 @@ def update_agent_config(conf: Config):
250
254
  if 'traces' in conf.sample_config and 'headSampling' in conf.sample_config['traces']:
251
255
  head_sampling_config = conf.sample_config['traces']['headSampling']
252
256
 
253
- # Determine if we have valid sampling config (rules or fallback fraction)
254
257
  if head_sampling_config and (
255
- head_sampling_config.get('attributesAndSamplerRules') or
256
- head_sampling_config.get('fallbackFraction') is not None
258
+ head_sampling_config.get('noisyOperations') is not None
257
259
  ):
258
260
  valid_config = head_sampling_config
259
261
  else:
@@ -370,11 +372,19 @@ class ProxyResource(Resource):
370
372
 
371
373
  def _after_in_child():
372
374
  # This hook runs in every child process after fork().
373
- # It does two things:
374
- # 1. Marks ProxyResource as "dirty", so the next time spans are created,
375
- # the process.* attributes are recomputed with the child’s PID.
375
+ # It does three things:
376
+ # 1. Marks ProxyResource as "dirty" so process.* attributes are recomputed.
376
377
  # 2. Restarts the OpAMP client (threads/sockets do not survive fork).
378
+ # 3. Propagates the sampler to the new client so config updates reach it.
379
+ # Without this, the forked client's sampler is None and config updates
380
+ # from OpAMP heartbeats are silently dropped.
377
381
  provider = get_tracer_provider()
378
382
  if isinstance(getattr(provider, "resource", None), ProxyResource):
379
383
  provider.resource._child_refreshed = False
380
- start_opamp_client(OpampConnectionEvent())
384
+ client = start_opamp_client(OpampConnectionEvent())
385
+
386
+ if _odigos_sampler is not None:
387
+ client.sampler = _odigos_sampler
388
+ initial_config = client.get_initial_sampler_config()
389
+ if initial_config is not None:
390
+ _odigos_sampler.update_config(initial_config)
@@ -0,0 +1,168 @@
1
+ from opentelemetry.semconv.attributes import http_attributes as http_attributes_semconv
2
+ 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
6
+
7
+
8
+ # Setup the Sampler logger
9
+ sampler_logger = logging.getLogger('odigos')
10
+
11
+ class OdigosSampler(Sampler):
12
+
13
+ # For compatibility with 64 bit trace IDs, the sampler checks the 64
14
+ # low-order bits of the trace ID to decide whether to sample a given trace.
15
+ TRACE_ID_LIMIT = (1 << 64) - 1
16
+
17
+ def __init__(self):
18
+ self._lock = Lock()
19
+ self._config = None
20
+
21
+ def _trace_id_based_sampling(self, trace_id: int, percentage_to_sample: float) -> bool:
22
+ """Mimic OpenTelemetry's trace ID-based sampling logic with 64-bit range."""
23
+ # Since percentage to sample is in percents and we need a fraction, divide by 100
24
+ fraction = percentage_to_sample / 100
25
+
26
+ # Calculate the bound based on the fraction using OpenTelemetry's approach
27
+ bound = round(fraction * (self.TRACE_ID_LIMIT + 1))
28
+ # Apply the TRACE_ID_LIMIT mask to ensure 64-bit range and compare it to the bound
29
+ return (trace_id & self.TRACE_ID_LIMIT) < bound
30
+
31
+ def should_sample(self, parent_context, trace_id, name, kind, attributes, links):
32
+ attributes = attributes or {}
33
+ with self._lock:
34
+ # sampler_logger.debug(f'Running Should_sample a span with the following attributes: {attributes}')
35
+
36
+ if self._config is None:
37
+ # sampler_logger.debug('No configuration is set, returning RECORD_AND_SAMPLE')
38
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
39
+
40
+ noisy_operations = self._config.get('noisyOperations', [])
41
+ lowest_percentage = None
42
+
43
+ for noisy_operation in noisy_operations:
44
+ if noisy_operation.get("disabled"):
45
+ continue
46
+
47
+ percentage_to_sample = noisy_operation.get("percentageAtMost", 0.0)
48
+
49
+ http_operation_sample = noisy_operation.get("operation")
50
+ if not http_operation_sample:
51
+ continue
52
+
53
+ # sampler_logger.debug(f'Evaluating noisy operation: {noisy_operation}')
54
+
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
59
+
60
+ if http_client_sample := http_operation_sample.get("httpClient"):
61
+ matched = self._match_http_client_sample_rule(http_client_sample, attributes) or matched
62
+
63
+ # 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):
72
+ # 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))
74
+ else:
75
+ # sampler_logger.debug(f'Trace [{trace_id}] is dropped with lowest percentage {lowest_percentage}')
76
+ return SamplingResult(Decision.DROP)
77
+
78
+ # sampler_logger.debug(f'No noisy operation matched, sampling the trace')
79
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
80
+
81
+ @staticmethod
82
+ def _get_attribute(span_attributes: dict[str, str], *keys: str):
83
+ """Return the first non-None value found for the given attribute keys."""
84
+ for key in keys:
85
+ value = span_attributes.get(key)
86
+ if value is not None:
87
+ return value
88
+ return None
89
+
90
+ def _match_http_server_sample_rule(self, http_server_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
91
+ """
92
+ Try to match all the http server's attributes to the definitions of the noisy action
93
+ """
94
+ span_route = span_attributes.get(http_attributes_semconv.HTTP_ROUTE)
95
+ # Old semconv: "http.method" → new semconv: "http.request.method"
96
+ span_method = self._get_attribute(span_attributes, http_attributes_semconv.HTTP_REQUEST_METHOD, "http.method")
97
+
98
+ # Some instrumentations send target instead of route
99
+ target = self._get_attribute(span_attributes, "http.target", "url.path")
100
+
101
+ if http_server_rule.get("route") and http_server_rule["route"] != span_route:
102
+ # If we have route in the span attributes, evaluate it first (use template matching
103
+ # since param names may differ, e.g. /grml/{grml_id} vs /grml/{path_param})
104
+ if span_route:
105
+ return self._match_rule_route_to_span(http_server_rule["route"], span_route)
106
+ # If we have target, try to match it with the route in the sampling rule
107
+ if target:
108
+ return self._match_rule_route_to_span(http_server_rule["route"], target)
109
+
110
+ if http_server_rule.get("routePrefix") and not (span_route or "").startswith(http_server_rule["routePrefix"]):
111
+ return False
112
+ if http_server_rule.get("method") and http_server_rule["method"] != span_method:
113
+ return False
114
+
115
+ return True
116
+
117
+ def _match_http_client_sample_rule(self, http_client_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
118
+ """
119
+ Try to match all the http client's attributes to the definitions of the noisy action
120
+ """
121
+ # Old semconv: "net.peer.name" / "http.host" → new semconv: "server.address"
122
+ server_address = self._get_attribute(span_attributes, server_attributes_semconv.SERVER_ADDRESS, "net.peer.name", "http.host")
123
+ url_template = span_attributes.get("url.template")
124
+ # Old semconv: "http.method" → new semconv: "http.request.method"
125
+ method = self._get_attribute(span_attributes, http_attributes_semconv.HTTP_REQUEST_METHOD, "http.method")
126
+
127
+ if http_client_rule.get("serverAddress") and http_client_rule["serverAddress"] != server_address:
128
+ return False
129
+ if http_client_rule.get("templatedPath") and http_client_rule["templatedPath"] != url_template:
130
+ return False
131
+ if http_client_rule.get("templatedPathPrefix") and not (url_template or "").startswith(http_client_rule["templatedPathPrefix"]):
132
+ return False
133
+ if http_client_rule.get("method") and http_client_rule["method"] != method:
134
+ return False
135
+
136
+ return True
137
+
138
+ def _match_rule_route_to_span(self, rule_route: str, span_value: str) -> bool:
139
+ """
140
+ Match a sampling rule route (e.g. /item/{id}) against a span value, which can be either
141
+ a templated route (e.g. /item/{item_id}) or a full URL target (e.g. /item/123?param=hello).
142
+ Path param segments ({...}) in the rule are treated as wildcards.
143
+ """
144
+ span_value = span_value.split('?')[0]
145
+ rule_parts = [part for part in rule_route.split('/') if part]
146
+ span_parts = [part for part in span_value.split('/') if part]
147
+
148
+ if len(rule_parts) != len(span_parts):
149
+ return False
150
+
151
+ # Traverse both rule and span segments
152
+ for i in range(len(rule_parts)):
153
+ # If we hit a path param in the rule, it matches any span segment
154
+ if rule_parts[i].startswith('{'):
155
+ continue
156
+ if rule_parts[i] != span_parts[i]:
157
+ return False
158
+
159
+ return True
160
+
161
+
162
+ def get_description(self):
163
+ return "OdigosSampler"
164
+
165
+ def update_config(self, new_config):
166
+ with self._lock:
167
+ # sampler_logger.debug(f'Updating the configuration with the new configuration: {new_config}')
168
+ self._config = new_config
@@ -0,0 +1 @@
1
+ VERSION = "1.0.79"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.78
3
+ Version: 1.0.79
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author-email: odigos-io <support@odigos.io>
6
6
  Requires-Python: >=3.9
@@ -9,7 +9,6 @@ initializer/lib_handling.py
9
9
  initializer/odigos_sampler.py
10
10
  initializer/post_instrument.py
11
11
  initializer/process_resource.py
12
- initializer/sampling_operators.py
13
12
  initializer/version.py
14
13
  initializer/distro/__init__.py
15
14
  initializer/distro/distro.py
@@ -452,8 +452,7 @@ class OpAMPHTTPClient:
452
452
 
453
453
  # Check if we have valid sampling config (same validation as update_agent_config)
454
454
  if head_sampling_config and (
455
- head_sampling_config.get('attributesAndSamplerRules') or
456
- head_sampling_config.get('fallbackFraction') is not None
455
+ head_sampling_config.get('noisyOperations') is not None
457
456
  ):
458
457
  return head_sampling_config
459
458
  else:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "odigos-opentelemetry-python"
7
- version = "1.0.78"
7
+ version = "1.0.79"
8
8
  description = "Odigos Initializer for Python OpenTelemetry Components"
9
9
  requires-python = ">=3.9"
10
10
  authors = [
@@ -1,98 +0,0 @@
1
- from .sampling_operators import SamplingOperators
2
- from opentelemetry.sdk.trace.sampling import Sampler, Decision, SamplingResult, _get_parent_trace_state
3
- from threading import Lock
4
- import logging
5
-
6
- # Setup the Sampler logger
7
- sampler_logger = logging.getLogger('odigos')
8
-
9
- class OdigosSampler(Sampler):
10
-
11
- # Define the sampling operator functions used in the configuration.
12
- # These operations are specified in the Odigos InstrumentationConfig Kubernetes object.
13
- _operations = {
14
- SamplingOperators.EQUALS.value: lambda attr, value: attr == value,
15
- SamplingOperators.NOT_EQUALS.value: lambda attr, value: attr != value,
16
- SamplingOperators.END_WITH.value: lambda attr, value: attr.endswith(value),
17
- SamplingOperators.START_WITH.value: lambda attr, value: attr.startswith(value)
18
- }
19
-
20
- # For compatibility with 64 bit trace IDs, the sampler checks the 64
21
- # low-order bits of the trace ID to decide whether to sample a given trace.
22
- TRACE_ID_LIMIT = (1 << 64) - 1
23
-
24
- def __init__(self):
25
- self._lock = Lock()
26
- self._config = None
27
-
28
- def _trace_id_based_sampling(self, trace_id, fraction):
29
- """Mimic OpenTelemetry's trace ID-based sampling logic with 64-bit range."""
30
- # Calculate the bound based on the fraction using OpenTelemetry's approach
31
- bound = round(fraction * (self.TRACE_ID_LIMIT + 1))
32
- # Apply the TRACE_ID_LIMIT mask to ensure 64-bit range and compare it to the bound
33
- return (trace_id & self.TRACE_ID_LIMIT) < bound
34
-
35
- def should_sample(self, parent_context, trace_id, name, kind, attributes, links):
36
- with self._lock:
37
- # sampler_logger.debug(f'Running Should_sample a span with the following attributes: {attributes}')
38
-
39
- if self._config is None:
40
- # sampler_logger.debug('No configuration is set, returning RECORD_AND_SAMPLE')
41
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
42
-
43
- rules = self._config.get('attributesAndSamplerRules', [])
44
- global_fraction = self._config.get('fallbackFraction', 1) # default to 1 if not set which means always sample
45
-
46
- for rule in rules: # The first attribute rule that evaluates to true is used to determine the sampling decision based on its fraction.
47
- and_attributes_sampler_rules = rule.get('attributeConditions', [])
48
-
49
- # sampler_logger.debug(f'"AND" rule operands are: {and_attributes_sampler_rules}')
50
-
51
- and_rule_fraction = rule.get('fraction', 1) # default to 1 if not set which means always sample
52
- and_rule_met = True
53
-
54
- for and_rule in and_attributes_sampler_rules:
55
- # If the "AND" rule is not met once, break the loop to avoid unnecessary checks
56
- if not and_rule_met:
57
- break
58
-
59
- key = and_rule.get('key')
60
- value = and_rule.get('val')
61
- operator = and_rule.get('operator') # equals / notEquals / endWith / startWith
62
-
63
- if key in attributes:
64
- attr_value = attributes[key]
65
- # Perform the corresponding operation
66
- if operator in self._operations and self._operations[operator](attr_value, value):
67
- pass # Condition matched
68
- else:
69
- # sampler_logger.debug(f'Operator {operator} is false, setting the "AND" rule flag to false')
70
- and_rule_met = False
71
- else:
72
- # sampler_logger.debug(f'Attribute {key} is not present in the span attributes, setting the "AND" rule flag to false')
73
- and_rule_met = False
74
-
75
- if and_rule_met:
76
- # Perform the sampling decision
77
- if self._trace_id_based_sampling(trace_id, and_rule_fraction):
78
- # sampler_logger.debug(f'Trace [{trace_id}] is sampled "And rules" are met with fraction {and_rule_fraction}')
79
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
80
- else:
81
- # sampler_logger.debug(f'Trace [{trace_id}] is dropped "And rules" are met but fraction {and_rule_fraction} not met')
82
- return SamplingResult(Decision.DROP)
83
-
84
- # Fallback to the global fraction if no rule matches
85
- # sampler_logger.debug(f'No rule matched, falling back to the global fraction {global_fraction}')
86
- if self._trace_id_based_sampling(trace_id, global_fraction):
87
- return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
88
- else:
89
- return SamplingResult(Decision.DROP)
90
-
91
-
92
- def get_description(self):
93
- return "OdigosSampler"
94
-
95
- def update_config(self, new_config):
96
- with self._lock:
97
- # sampler_logger.debug(f'Updating the configuration with the new configuration: {new_config}')
98
- self._config = new_config
@@ -1,7 +0,0 @@
1
- from enum import Enum
2
-
3
- class SamplingOperators(str, Enum):
4
- EQUALS = "equals"
5
- NOT_EQUALS = "notEquals"
6
- END_WITH = "endWith"
7
- START_WITH = "startWith"
@@ -1 +0,0 @@
1
- VERSION = "1.0.78"