odigos-opentelemetry-python 1.0.80__tar.gz → 1.0.81__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.81}/PKG-INFO +1 -1
  2. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/odigos_sampler.py +91 -35
  3. odigos_opentelemetry_python-1.0.81/initializer/schemas/sampling.py +39 -0
  4. odigos_opentelemetry_python-1.0.81/initializer/version.py +1 -0
  5. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/PKG-INFO +1 -1
  6. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/SOURCES.txt +2 -0
  7. odigos_opentelemetry_python-1.0.81/opamp/__init__.py +0 -0
  8. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/http_client.py +2 -2
  9. odigos_opentelemetry_python-1.0.81/opamp/utils.py +26 -0
  10. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/pyproject.toml +38 -1
  11. odigos_opentelemetry_python-1.0.80/initializer/version.py +0 -1
  12. odigos_opentelemetry_python-1.0.80/opamp/utils.py +0 -26
  13. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/LICENSE +0 -0
  14. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/README.md +0 -0
  15. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/__init__.py +0 -0
  16. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/components.py +0 -0
  17. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/distro/__init__.py +0 -0
  18. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/distro/distro.py +0 -0
  19. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/distro/instrumentation_registry.py +0 -0
  20. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/envs.py +0 -0
  21. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/exit_hook.py +0 -0
  22. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/lib_handling.py +0 -0
  23. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/post_instrument.py +0 -0
  24. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/initializer/process_resource.py +0 -0
  25. {odigos_opentelemetry_python-1.0.80/opamp → odigos_opentelemetry_python-1.0.81/initializer/schemas}/__init__.py +0 -0
  26. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/dependency_links.txt +0 -0
  27. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/entry_points.txt +0 -0
  28. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/requires.txt +0 -0
  29. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/odigos_opentelemetry_python.egg-info/top_level.txt +0 -0
  30. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/anyvalue_pb2.py +0 -0
  31. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/config.py +0 -0
  32. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/health_status.py +0 -0
  33. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/opamp_pb2.py +0 -0
  34. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/opamp/opamp_registry.py +0 -0
  35. {odigos_opentelemetry_python-1.0.80 → odigos_opentelemetry_python-1.0.81}/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.81
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,34 +1,55 @@
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
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
20
32
 
21
33
  def _trace_id_based_sampling(self, trace_id: int, percentage_to_sample: float) -> bool:
22
34
  """Mimic OpenTelemetry's trace ID-based sampling logic with 64-bit range."""
23
35
  # Since percentage to sample is in percents and we need a fraction, divide by 100
24
36
  fraction = percentage_to_sample / 100
25
-
37
+
26
38
  # Calculate the bound based on the fraction using OpenTelemetry's approach
27
39
  bound = round(fraction * (self.TRACE_ID_LIMIT + 1))
28
40
  # Apply the TRACE_ID_LIMIT mask to ensure 64-bit range and compare it to the bound
29
41
  return (trace_id & self.TRACE_ID_LIMIT) < bound
30
42
 
31
- def should_sample(self, parent_context, trace_id, name, kind, attributes, links):
43
+ def should_sample(
44
+ self,
45
+ parent_context: Optional[Context],
46
+ trace_id: int,
47
+ name: str,
48
+ kind: Optional[SpanKind] = None,
49
+ attributes: Attributes = None,
50
+ links: Optional[Sequence["Link"]] = None,
51
+ trace_state: Optional[TraceState] = None,
52
+ ) -> "SamplingResult":
32
53
  attributes = attributes or {}
33
54
  with self._lock:
34
55
  # sampler_logger.debug(f'Running Should_sample a span with the following attributes: {attributes}')
@@ -37,49 +58,64 @@ class OdigosSampler(Sampler):
37
58
  # sampler_logger.debug('No configuration is set, returning RECORD_AND_SAMPLE')
38
59
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
39
60
 
40
- noisy_operations = self._config.get('noisyOperations', [])
41
- lowest_percentage = None
61
+ is_dry_run: bool = self._config.get('dryRun', False)
62
+ noisy_operations: list[NoisyOperation] = self._config.get('noisyOperations', [])
63
+ # Track the rule with the lowest percentageAtMost so the tracestate entry reports the actual deciding rule's id (first-wins on ties)
64
+ winning_operation: Optional[NoisyOperation] = None
42
65
 
43
66
  for noisy_operation in noisy_operations:
44
67
  if noisy_operation.get("disabled"):
45
68
  continue
46
69
 
47
- percentage_to_sample = noisy_operation.get("percentageAtMost", 0.0)
70
+ percentage_to_sample: float = noisy_operation.get("percentageAtMost", 0.0)
48
71
 
49
- http_operation_sample = noisy_operation.get("operation")
50
- if not http_operation_sample:
51
- continue
72
+ http_operation_sample: Optional[HeadSamplingOperationMatcher] = noisy_operation.get("operation")
52
73
 
53
74
  # sampler_logger.debug(f'Evaluating noisy operation: {noisy_operation}')
54
75
 
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
76
+ # No operation matcher -> rule applies workload-wide (matches every span)
77
+ # This supports Sampling rules that scope by sourceScopes (workload) without specific HTTP matcher
78
+ if not http_operation_sample:
79
+ matched = True
80
+ else: # When we have a granular operation, check it vs the server/client rules specified in the noisy operation
81
+ matched = False
82
+ if http_server_sample := http_operation_sample.get("httpServer"):
83
+ matched = self._match_http_server_sample_rule(http_server_sample, attributes) or matched
59
84
 
60
- if http_client_sample := http_operation_sample.get("httpClient"):
61
- matched = self._match_http_client_sample_rule(http_client_sample, attributes) or matched
85
+ if http_client_sample := http_operation_sample.get("httpClient"):
86
+ matched = self._match_http_client_sample_rule(http_client_sample, attributes) or matched
62
87
 
63
88
  # 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):
89
+ if matched and (winning_operation is None or percentage_to_sample < winning_operation.get("percentageAtMost", 0.0)):
90
+ winning_operation = noisy_operation
91
+
92
+ if winning_operation is not None:
93
+ # Build tracestate from the winning rule and attach it to both decisions.
94
+ # Even on DROP the SpanContext (and its trace_state) is propagated to downstream services via the tracestate header,
95
+ # enabling consistent head-sampling and tail-sampler decisions across the trace.
96
+ lowest_percentage = winning_operation.get("percentageAtMost", 0.0)
97
+ sampled = self._trace_id_based_sampling(trace_id, lowest_percentage)
98
+
99
+ dry_run_sampled = sampled if is_dry_run else None
100
+ trace_state = self.build_tracestate(parent_context, winning_operation, dry_run_sampled)
101
+
102
+ # In dry-run mode, never actually drop spans
103
+ if is_dry_run:
104
+ # sampler_logger.debug(f'Dry run [{trace_id}] would_be_sampled={sampled} (lowest_percentage={lowest_percentage})')
105
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=trace_state)
106
+
107
+ if sampled:
72
108
  # 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))
109
+ return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=trace_state)
74
110
  else:
75
111
  # sampler_logger.debug(f'Trace [{trace_id}] is dropped with lowest percentage {lowest_percentage}')
76
- return SamplingResult(Decision.DROP)
112
+ return SamplingResult(Decision.DROP, trace_state=trace_state)
77
113
 
78
- # sampler_logger.debug(f'No noisy operation matched, sampling the trace')
114
+ # sampler_logger.debug('No noisy operation matched, sampling the trace')
79
115
  return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=attributes, trace_state=_get_parent_trace_state(parent_context))
80
116
 
81
117
  @staticmethod
82
- def _get_attribute(span_attributes: dict[str, str], *keys: str):
118
+ def _get_attribute(span_attributes: Mapping[str, Any], *keys: str):
83
119
  """Return the first non-None value found for the given attribute keys."""
84
120
  for key in keys:
85
121
  value = span_attributes.get(key)
@@ -87,7 +123,7 @@ class OdigosSampler(Sampler):
87
123
  return value
88
124
  return None
89
125
 
90
- def _match_http_server_sample_rule(self, http_server_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
126
+ def _match_http_server_sample_rule(self, http_server_rule: HeadSamplingHttpServerOperationMatcher, span_attributes: Mapping[str, Any]) -> bool:
91
127
  """
92
128
  Try to match all the http server's attributes to the definitions of the noisy action
93
129
  """
@@ -106,7 +142,7 @@ class OdigosSampler(Sampler):
106
142
  # If we have target, try to match it with the route in the sampling rule
107
143
  if target:
108
144
  return self._match_rule_route_to_span(http_server_rule["route"], target)
109
-
145
+
110
146
  if http_server_rule.get("routePrefix") and not (span_route or "").startswith(http_server_rule["routePrefix"]):
111
147
  return False
112
148
  if http_server_rule.get("method") and http_server_rule["method"] != span_method:
@@ -114,7 +150,7 @@ class OdigosSampler(Sampler):
114
150
 
115
151
  return True
116
152
 
117
- def _match_http_client_sample_rule(self, http_client_rule: dict[str, str], span_attributes: dict[str, str]) -> bool:
153
+ def _match_http_client_sample_rule(self, http_client_rule: HeadSamplingHttpClientOperationMatcher, span_attributes: Mapping[str, Any]) -> bool:
118
154
  """
119
155
  Try to match all the http client's attributes to the definitions of the noisy action
120
156
  """
@@ -158,7 +194,6 @@ class OdigosSampler(Sampler):
158
194
 
159
195
  return True
160
196
 
161
-
162
197
  def get_description(self):
163
198
  return "OdigosSampler"
164
199
 
@@ -166,3 +201,24 @@ class OdigosSampler(Sampler):
166
201
  with self._lock:
167
202
  # sampler_logger.debug(f'Updating the configuration with the new configuration: {new_config}')
168
203
  self._config = new_config
204
+
205
+ def build_tracestate(
206
+ self,
207
+ parent_context: Optional[Context],
208
+ noisy_operation: NoisyOperation,
209
+ dry_run_sampled: Optional[bool] = None,
210
+ ) -> TraceState:
211
+ tracestate = _get_parent_trace_state(parent_context)
212
+
213
+ if not tracestate:
214
+ tracestate = TraceState()
215
+
216
+ # Round percentage to 2 decimal places for byte-identical tracestate values
217
+ percentage = round(noisy_operation.get("percentageAtMost", 0.0), 2)
218
+ noisy_op_tracestate_value = f"c:n;dr.p:{percentage};dr.id:{noisy_operation['id']}"
219
+
220
+ if dry_run_sampled is not None:
221
+ noisy_op_tracestate_value += ";dry:t" if dry_run_sampled else ";dry:f"
222
+ tracestate = tracestate.add("odigos", noisy_op_tracestate_value)
223
+
224
+ return tracestate
@@ -0,0 +1,39 @@
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
+ noisyOperations: list[NoisyOperation]
@@ -0,0 +1 @@
1
+ VERSION = "1.0.81"
@@ -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.81
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
@@ -147,8 +147,8 @@ class OpAMPHTTPClient:
147
147
  # The default config is preloaded when the EBPFSpanProcessor is initialized
148
148
  pass
149
149
 
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)
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)
152
152
 
153
153
  # Store initial sampler config for direct access
154
154
  remote_config = self.get_remote_config(first_message_server_to_agent)
@@ -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.81"
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,33 @@ 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.format]
149
+ quote-style = "preserve"
150
+ indent-style = "space"
151
+
152
+ [tool.ty.environment]
153
+ python-version = "3.9"
154
+
155
+ [tool.ty.src]
156
+ exclude = [
157
+ ".venv",
158
+ "dist",
159
+ "build",
160
+ "tests/integration/configurator",
161
+ ]
@@ -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
- }