odigos-opentelemetry-python 1.0.45__tar.gz → 1.0.46__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 (26) hide show
  1. {odigos_opentelemetry_python-1.0.45/odigos_opentelemetry_python.egg-info → odigos_opentelemetry_python-1.0.46}/PKG-INFO +1 -1
  2. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/components.py +77 -37
  3. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46/odigos_opentelemetry_python.egg-info}/PKG-INFO +1 -1
  4. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/odigos_opentelemetry_python.egg-info/SOURCES.txt +1 -0
  5. odigos_opentelemetry_python-1.0.46/opamp/config.py +58 -0
  6. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/http_client.py +139 -64
  7. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/utils.py +6 -6
  8. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/setup.py +1 -1
  9. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/LICENSE +0 -0
  10. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/README.md +0 -0
  11. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/__init__.py +0 -0
  12. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/envs.py +0 -0
  13. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/exit_hook.py +0 -0
  14. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/lib_handling.py +0 -0
  15. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/odigos_sampler.py +0 -0
  16. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/process_resource.py +0 -0
  17. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/sampling_operators.py +0 -0
  18. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/initializer/version.py +0 -0
  19. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/odigos_opentelemetry_python.egg-info/dependency_links.txt +0 -0
  20. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/odigos_opentelemetry_python.egg-info/requires.txt +0 -0
  21. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/odigos_opentelemetry_python.egg-info/top_level.txt +0 -0
  22. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/__init__.py +0 -0
  23. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/anyvalue_pb2.py +0 -0
  24. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/health_status.py +0 -0
  25. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/opamp/opamp_pb2.py +0 -0
  26. {odigos_opentelemetry_python-1.0.45 → odigos_opentelemetry_python-1.0.46}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.45
3
+ Version: 1.0.46
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author: Tamir David
6
6
  Author-email: tamir@odigos.io
@@ -6,13 +6,14 @@ import threading
6
6
  import atexit
7
7
  import sys
8
8
  import os
9
+
9
10
  import opentelemetry.sdk._configuration as sdk_config
10
11
  from .process_resource import OdigosProcessResourceDetector
11
12
  from opentelemetry.sdk.resources import Resource
12
13
  from opentelemetry.sdk.resources import OTELResourceDetector
13
14
  from opentelemetry.sdk.trace import TracerProvider
14
15
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
15
- from opentelemetry.trace import set_tracer_provider
16
+ from opentelemetry.trace import set_tracer_provider, get_tracer_provider
16
17
 
17
18
  from .version import VERSION
18
19
  from .exit_hook import ExitHooks
@@ -21,6 +22,8 @@ from .envs import set_static_otel_env
21
22
  from .odigos_sampler import OdigosSampler
22
23
  from opentelemetry.sdk.trace.sampling import ParentBased
23
24
 
25
+ from opamp.config import Config
26
+
24
27
  # Reorder the python sys.path to ensure that the user application's dependencies take precedence over the agent's dependencies.
25
28
  # This is necessary because the user application's dependencies may be incompatible with those used by the agent.
26
29
  reorder_python_path()
@@ -29,35 +32,35 @@ from opamp.http_client import OpAMPHTTPClient, MockOpAMPClient
29
32
 
30
33
  MINIMUM_PYTHON_SUPPORTED_VERSION = (3, 8)
31
34
 
35
+
32
36
  def initialize_components(trace_exporters = False, span_processor = None):
33
37
  # In case of forking, the OpAMP client should be started in the child process.
34
38
  # e.g when using gunicorn/celery with multiple workers.
35
39
  os.register_at_fork(
36
40
  after_in_child=lambda: start_opamp_client(threading.Event()),
37
41
  ) # pylint: disable=protected-access
38
-
42
+
39
43
  handle_instrumenation_of_sub_processes()
40
44
 
41
45
  resource_attributes_event = threading.Event()
42
46
  client = None
43
-
47
+
44
48
  try:
45
-
49
+
46
50
  client = start_opamp_client(resource_attributes_event)
47
51
  resource_attributes_event.wait(timeout=30) # Wait for the resource attributes to be received for 30 seconds
48
52
 
49
53
  supported_signals = client.signals
50
54
  received_value = client.resource_attributes
51
-
52
- if received_value:
53
-
55
+
56
+ if received_value:
54
57
  handle_django_instrumentation()
55
-
58
+
56
59
  auto_resource = {
57
60
  "telemetry.distro.name": "odigos",
58
61
  "telemetry.distro.version": VERSION,
59
62
  }
60
-
63
+
61
64
  auto_resource.update(received_value)
62
65
 
63
66
  resource = OdigosProcessResourceDetector(client.pid).detect() \
@@ -70,15 +73,15 @@ def initialize_components(trace_exporters = False, span_processor = None):
70
73
 
71
74
  initialize_metrics_if_enabled(resource, supported_signals)
72
75
  initialize_logging_if_enabled(resource, supported_signals)
73
-
74
- else:
76
+
77
+ else:
75
78
  raise Exception("Did not receive resource attributes from the OpAMP server.")
76
-
79
+
77
80
  except Exception as e:
78
81
  if client is not None:
79
82
  client.shutdown(custom_failure_message=str(e))
80
83
  raise
81
-
84
+
82
85
  finally:
83
86
  # Make sure the distro modules are reloaded even if an exception is raised.
84
87
  reload_distro_modules()
@@ -86,21 +89,21 @@ def initialize_components(trace_exporters = False, span_processor = None):
86
89
  def initialize_traces_if_enabled(trace_exporters, resource, span_processor = None, signals = None):
87
90
  # In case collectors signals support receiving traces, initialize the tracer provider with the exporters
88
91
  traces_enabled = signals.get("traceSignal", False)
89
-
92
+
90
93
  if traces_enabled:
91
-
94
+
92
95
  odigos_sampler = OdigosSampler()
93
96
  sampler = ParentBased(odigos_sampler)
94
-
97
+
95
98
  # Exporting using exporters
96
99
  if trace_exporters:
97
100
  provider = TracerProvider(resource=resource, sampler=sampler)
98
101
  id_generator_name = sdk_config._get_id_generator()
99
- id_generator = sdk_config._import_id_generator(id_generator_name)
102
+ id_generator = sdk_config._import_id_generator(id_generator_name)
100
103
  provider.id_generator = id_generator
101
-
104
+
102
105
  set_tracer_provider(provider)
103
-
106
+
104
107
  exporters, _, _ = sdk_config._import_exporters(["otlp_proto_http"], [], [])
105
108
 
106
109
  for _, exporter_class in exporters.items():
@@ -108,14 +111,14 @@ def initialize_traces_if_enabled(trace_exporters, resource, span_processor = Non
108
111
  provider.add_span_processor(
109
112
  BatchSpanProcessor(exporter_class(**exporter_args))
110
113
  )
111
-
114
+
112
115
  # Exporting using EBPF
113
116
  else:
114
117
  provider = TracerProvider(resource=resource, sampler=sampler)
115
118
  set_tracer_provider(provider)
116
119
  if span_processor is not None:
117
120
  provider.add_span_processor(span_processor)
118
-
121
+
119
122
  return odigos_sampler
120
123
 
121
124
  return None
@@ -123,17 +126,17 @@ def initialize_traces_if_enabled(trace_exporters, resource, span_processor = Non
123
126
  def initialize_metrics_if_enabled(resource, signals):
124
127
  # Currently we're not exporting metrics using the OTEL exporter.
125
128
  metrics_enabled = False
126
-
129
+
127
130
  if metrics_enabled:
128
131
  _, exporters, _ = sdk_config._import_exporters([], ["otlp_proto_http"], [])
129
-
132
+
130
133
  sdk_config._init_metrics(exporters, resource)
131
134
 
132
135
 
133
136
  def initialize_logging_if_enabled(resource, signals):
134
137
  # Logs are collected using the filelog receiver; currently, there is no need to export them using the OTEL exporter.
135
138
  logging_enabled = False
136
-
139
+
137
140
  if logging_enabled:
138
141
  _, _, exporters = sdk_config._import_exporters([], [], ["otlp_proto_http"])
139
142
  sdk_config._init_logging(exporters, resource)
@@ -142,20 +145,23 @@ def initialize_logging_if_enabled(resource, signals):
142
145
  def start_opamp_client(event):
143
146
  if os.getenv('DISABLE_OPAMP_CLIENT', 'false').strip().lower() == 'true':
144
147
  return MockOpAMPClient(event)
145
-
148
+
146
149
  # Setting static otel envs such as log correlation and exporter protocols
147
150
  set_static_otel_env()
148
-
151
+
149
152
  condition = threading.Condition(threading.Lock())
150
153
  client = OpAMPHTTPClient(event, condition)
151
-
154
+
155
+ # Register the configuration update callback for the processor
156
+ client.register_config_update_cb(update_agent_config)
157
+
152
158
  python_version_supported = is_supported_python_version()
153
159
 
154
160
  client.start(python_version_supported)
155
-
161
+
156
162
  hooks = ExitHooks()
157
163
  hooks.hook()
158
-
164
+
159
165
  def shutdown():
160
166
  if hooks.exit_code is not None and hooks.exit_code != 0:
161
167
  client.shutdown("Program exit with code: " + str(hooks.exit_code))
@@ -169,31 +175,65 @@ def start_opamp_client(event):
169
175
 
170
176
  return client
171
177
 
178
+ def update_agent_config(conf: Config):
179
+ """
180
+ The function `update_agent_config` checks for a tracer provider and updates its configuration using
181
+ processors if available.
182
+ It searches specifically for the EBPFSpanProcessor processes (Looking for the update_config attribute).
183
+
184
+ :param conf: The `conf` parameter in the `update_agent_config` function is of type `Config`. It is
185
+ used to update the configuration settings of the agent
186
+ :type conf: Config
187
+ :return: If the `provider` is `None` or if `active_proc` is `None`, then the function will return
188
+ without performing any further actions.
189
+ """
190
+ provider = get_tracer_provider()
191
+ if provider is None:
192
+ return
193
+
194
+ # The provider’s public API does not expose processors directly,
195
+ # but the private _active_span_processor attribute always exists.
196
+ active_proc = getattr(provider, "_active_span_processor", None)
197
+ if active_proc is None:
198
+ return
199
+
200
+ # If you attached only one BatchSpanProcessor, _active_span_processor
201
+ # *is* that object. If you attached several, it is a MultiSpanProcessor
202
+ # that wraps them in ._span_processors.
203
+ processors = (
204
+ active_proc._span_processors
205
+ if hasattr(active_proc, "_span_processors")
206
+ else [active_proc]
207
+ )
208
+
209
+ for proc in processors:
210
+ if hasattr(proc, "update_config"):
211
+ proc.update_config(conf)
172
212
 
173
213
  def is_supported_python_version():
174
214
  return sys.version_info >= MINIMUM_PYTHON_SUPPORTED_VERSION
175
215
 
176
216
  def handle_instrumenation_of_sub_processes():
177
217
  # Resolves a path management issue introduced by OpenTelemetry's sitecustomize implementation.
178
- # OpenTelemetry removed auto_instrumentation from the path to address a specific bug
218
+ # OpenTelemetry removed auto_instrumentation from the path to address a specific bug
179
219
  # (ref: https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1050).
180
- # This modification does not impact our use case, as we utilize auto_instrumentation as a package
220
+ # This modification does not impact our use case, as we utilize auto_instrumentation as a package
181
221
  # and do not rely on opentelemetry-instrument for running instrumentations.
182
- #
222
+ #
183
223
  # We are reintroducing the path to enable parallel execution with other agent that inject themselves via sitecustomize.
184
- # We've observed that agents attempt to import user-defined sitecustomize.py [OpenTelemetry one in this case] prior to their own execution
224
+ # We've observed that agents attempt to import user-defined sitecustomize.py [OpenTelemetry one in this case] prior to their own execution
185
225
  # to prevent application logic disruption.
186
- #
226
+ #
187
227
  # Given OpenTelemetry's path removal during Distro creation, we must manually restore the path.
188
228
  # This addresses cases where applications using os.exec* are not properly instrumented.
189
- #
229
+ #
190
230
  # Note: This is a temporary solution and should be refactored when:
191
231
  # - The environment override writer is removed
192
232
  # - Webhook integration is implemented
193
233
  # - A custom distro creation mechanism is developed
194
234
  auto_instrumentation_path = "/var/odigos/python/opentelemetry/instrumentation/auto_instrumentation"
195
235
  python_path = os.getenv("PYTHONPATH", "")
196
-
236
+
197
237
  if auto_instrumentation_path not in python_path:
198
238
  new_python_path = f"{python_path}:{auto_instrumentation_path}" if python_path else auto_instrumentation_path
199
- os.environ["PYTHONPATH"] = new_python_path
239
+ os.environ["PYTHONPATH"] = new_python_path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: odigos-opentelemetry-python
3
- Version: 1.0.45
3
+ Version: 1.0.46
4
4
  Summary: Odigos Initializer for Python OpenTelemetry Components
5
5
  Author: Tamir David
6
6
  Author-email: tamir@odigos.io
@@ -17,6 +17,7 @@ odigos_opentelemetry_python.egg-info/requires.txt
17
17
  odigos_opentelemetry_python.egg-info/top_level.txt
18
18
  opamp/__init__.py
19
19
  opamp/anyvalue_pb2.py
20
+ opamp/config.py
20
21
  opamp/health_status.py
21
22
  opamp/http_client.py
22
23
  opamp/opamp_pb2.py
@@ -0,0 +1,58 @@
1
+ import re
2
+ from dataclasses import dataclass, fields, field
3
+ from typing import Any, Dict, Type, TypeVar, get_type_hints
4
+
5
+ T = TypeVar("T")
6
+
7
+ ### Configuration structs ###
8
+ class DefaultNoneMixin:
9
+ #__getattr__ is only invoked when the normal attribute lookup (__dict__, class attributes, etc.) fails.
10
+ # Prevent raising a ValueError when accessing a non existing key
11
+ # It'll save us multiple try catch blocks in the future
12
+ def __getattr__(self, name: str):
13
+ return None
14
+
15
+ @dataclass
16
+ class CodeAttributes(DefaultNoneMixin):
17
+ column: bool = False
18
+ file_path: bool = False
19
+ function: bool = False
20
+ line_number: bool = False
21
+ namespace: bool = False
22
+ stack_trace: bool = False
23
+
24
+ @dataclass
25
+ class Config(DefaultNoneMixin):
26
+ code_attributes: CodeAttributes = field(default_factory=CodeAttributes)
27
+
28
+ ### Helpers ###
29
+
30
+ def camel_to_snake(name: str) -> str:
31
+ """Convert camelCase or PascalCase to snake_case."""
32
+ s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
33
+ return s1.lower()
34
+
35
+ def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
36
+ """
37
+ Instantiate the dataclass `cls` from `data`:
38
+ - Converts JSON camelCase keys to snake_case field names
39
+ - Ignores unknown keys
40
+ - Uses defaults for any missing fields
41
+ - Recurses if a field’s type is itself a dataclass
42
+ """
43
+ hints = get_type_hints(cls)
44
+ field_names = {f.name for f in fields(cls)}
45
+ init_args: Dict[str, Any] = {}
46
+ for raw_key, raw_val in data.items():
47
+ key = camel_to_snake(raw_key)
48
+ if key not in field_names:
49
+ continue
50
+
51
+ field_type = hints.get(key)
52
+ # If the field type is another dataclass, recurse
53
+ if hasattr(field_type, "__dataclass_fields__") and isinstance(raw_val, dict):
54
+ init_args[key] = from_dict(field_type, raw_val)
55
+ else:
56
+ init_args[key] = raw_val
57
+
58
+ return cls(**init_args)
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import sys
3
+ import json
3
4
  import time
5
+ import base64
4
6
  import threading
5
7
  import requests_odigos
6
8
  import logging
@@ -18,9 +20,14 @@ from opamp import opamp_pb2, anyvalue_pb2, utils
18
20
  from opamp.health_status import AgentHealthStatus
19
21
  from initializer.process_resource import PROCESS_VPID
20
22
 
23
+ from google.protobuf.json_format import MessageToDict
24
+
25
+ from opamp.config import from_dict, Config
26
+
21
27
  # Setup the logger
22
28
  opamp_logger = logging.getLogger('odigos')
23
29
 
30
+ _debugger_instance = None
24
31
 
25
32
  env_var_mappings = {
26
33
  "ODIGOS_WORKLOAD_NAMESPACE": ResourceAttributes.K8S_NAMESPACE_NAME,
@@ -42,72 +49,72 @@ class OpAMPHTTPClient:
42
49
  self.remote_config_status = None
43
50
  self.sampler = None # OdigosSampler instance
44
51
  self.pid = os.getpid()
52
+ self.update_conf_cb = None # Callback for configuration updates in the processor
45
53
 
46
54
 
47
55
  def start(self, python_version_supported: bool = None):
48
56
  if not python_version_supported:
49
-
57
+
50
58
  python_version = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
51
59
  error_message = f"Opentelemetry SDK require Python in version 3.8 or higher [{python_version} is not supported]"
52
-
60
+
53
61
  # opamp_logger.warning(f"{error_message}, sending disconnect message to OpAMP server...")
54
62
  self.send_unsupported_version_disconnect_message(error_message=error_message)
55
63
  self.event.set()
56
64
  return
57
-
65
+
58
66
  self.client_thread = threading.Thread(target=self.run, name="OpAMPClientThread", daemon=True)
59
67
  self.client_thread.start()
60
-
68
+
61
69
  def run(self):
62
70
  try:
63
71
  if not self.mandatory_env_vars_set():
64
72
  self.event.set()
65
73
  return
66
-
74
+
67
75
  self.send_first_message_with_retry()
68
76
  self.event.set()
69
-
77
+
70
78
  self.worker()
71
-
79
+
72
80
  except Exception as e:
73
81
  # opamp_logger.error(f"Error running OpAMP client: {e}")
74
- failure_message = self.get_agent_failure_disconnect_message(error_message=str(e))
82
+ failure_message = self.get_agent_failure_disconnect_message(error_message=str(e))
75
83
  self.send_agent_to_server_message(failure_message)
76
-
84
+
77
85
  # Exiting the opamp thread and set the event to notify the main thread
78
86
  self.event.set()
79
87
  sys.exit()
80
88
 
81
89
  def get_agent_failure_disconnect_message(self, error_message: str, component_health: bool = False) -> None:
82
90
  agent_failure_message = opamp_pb2.AgentToServer()
83
-
91
+
84
92
  agent_disconnect = self.get_agent_disconnect()
85
93
  agent_failure_message.agent_disconnect.CopyFrom(agent_disconnect)
86
-
94
+
87
95
  agent_health = self.get_agent_health(component_health=component_health, last_error=error_message, status=AgentHealthStatus.AGENT_FAILURE.value)
88
96
  agent_failure_message.health.CopyFrom(agent_health)
89
-
97
+
90
98
  return agent_failure_message
91
-
99
+
92
100
  def send_unsupported_version_disconnect_message(self, error_message: str) -> None:
93
101
  first_disconnect_message = opamp_pb2.AgentToServer()
94
-
102
+
95
103
  agent_description = self.get_agent_description()
96
-
104
+
97
105
  first_disconnect_message.agent_description.CopyFrom(agent_description)
98
-
106
+
99
107
  agent_disconnect = self.get_agent_disconnect()
100
108
  first_disconnect_message.agent_disconnect.CopyFrom(agent_disconnect)
101
-
109
+
102
110
  agent_health = self.get_agent_health(component_health=False, last_error=error_message, status=AgentHealthStatus.UNSUPPORTED_RUNTIME_VERSION.value)
103
111
  first_disconnect_message.health.CopyFrom(agent_health)
104
-
112
+
105
113
  self.send_agent_to_server_message(first_disconnect_message)
106
-
114
+
107
115
  def send_first_message_with_retry(self) -> None:
108
116
  max_retries = 5
109
117
  delay = 2
110
-
111
118
  for attempt in range(1, max_retries + 1):
112
119
  try:
113
120
  # Send first message to OpAMP server, Health is false as the component is not initialized
@@ -117,28 +124,36 @@ class OpAMPHTTPClient:
117
124
 
118
125
  # Check if the response of the first message is empty
119
126
  # It may happen if OpAMPServer is not available
120
- if first_message_server_to_agent.ListFields():
121
- self.update_remote_config_status(first_message_server_to_agent)
122
-
127
+ if first_message_server_to_agent.ListFields():
128
+ if self.update_remote_config_status(server_to_agent):
129
+ if server_to_agent.HasField("remote_config") and self.update_conf_cb:
130
+ try:
131
+ remote_config = self.get_remote_config(server_to_agent)
132
+ self.update_conf_cb(remote_config)
133
+ except Exception:
134
+ # Catch exception and don't update the config
135
+ # The default config is preloaded when the EBPFSpanProcessor is initialized
136
+ pass
137
+
123
138
  sdk_config = utils.get_sdk_config(first_message_server_to_agent.remote_config.config.config_map)
124
139
  self.resource_attributes = utils.parse_first_message_to_resource_attributes(sdk_config, opamp_logger)
125
140
  self.signals = utils.parse_first_message_signals(sdk_config, opamp_logger)
126
-
141
+
127
142
  # Send healthy message to OpAMP server
128
143
  # opamp_logger.info("Reporting healthy to OpAMP server...")
129
144
  agent_health = self.get_agent_health(component_health=True, status=AgentHealthStatus.HEALTHY.value)
130
145
  self.send_agent_to_server_message(opamp_pb2.AgentToServer(health=agent_health))
131
-
146
+
132
147
  # Return if the first message was successfully sent
133
148
  return
134
-
149
+
135
150
  except Exception as e:
136
151
  # opamp_logger.error(f"Attempt {attempt}/{max_retries} failed. Error sending full state to OpAMP server: {e}")
137
152
  pass
138
-
153
+
139
154
  if attempt < max_retries:
140
155
  time.sleep(delay)
141
-
156
+
142
157
  # If all attempts failed, raise exception before starting the worker
143
158
  raise Exception(f"Error sending first message to OpAMP server after {max_retries} attempts")
144
159
 
@@ -149,28 +164,86 @@ class OpAMPHTTPClient:
149
164
  try:
150
165
  server_to_agent = self.send_heartbeat()
151
166
  if self.update_remote_config_status(server_to_agent):
152
- # opamp_logger.info("Remote config updated, applying changes...")
153
- pass
154
- # TODO: implement changes based on the remote config
167
+ if server_to_agent.HasField("remote_config") and self.update_conf_cb:
168
+ try:
169
+ remote_config = self.get_remote_config(server_to_agent)
170
+ self.update_conf_cb(remote_config)
171
+ except Exception:
172
+ # Catch exception and don't update the config
173
+ # The default config is preloaded when the EBPFSpanProcessor is initialized
174
+ pass
155
175
 
156
176
  if server_to_agent.flags & opamp_pb2.ServerToAgentFlags_ReportFullState:
157
177
  # opamp_logger.info("Received request to report full state")
158
-
178
+
159
179
  agent_description = self.get_agent_description()
160
180
  agent_health = self.get_agent_health(component_health=True, status=AgentHealthStatus.HEALTHY.value)
161
181
  agent_to_server = opamp_pb2.AgentToServer(agent_description=agent_description, health=agent_health)
162
-
182
+
163
183
  server_to_agent = self.send_agent_to_server_message(agent_to_server)
164
-
165
- self.update_remote_config_status(server_to_agent)
166
-
184
+
185
+ if self.update_remote_config_status(server_to_agent):
186
+ if server_to_agent.HasField("remote_config") and self.update_conf_cb:
187
+ try:
188
+ remote_config = self.get_remote_config(server_to_agent)
189
+ self.update_conf_cb(remote_config)
190
+ except Exception:
191
+ # Catch exception and don't update the config
192
+ # The default config is preloaded when the EBPFSpanProcessor is initialized
193
+ pass
194
+
167
195
  except requests_odigos.RequestException as e:
168
196
  # opamp_logger.error(f"Error fetching data: {e}")
169
197
  pass
170
198
  self.condition.wait(30)
171
199
 
200
+ def get_remote_config(self, message) -> dict:
201
+ """
202
+ Given a Protobuf message with a 'remote_config' field,
203
+ decode all bytes and base64-encoded subfields into a clean Python dict.
204
+ """
205
+ proto_dict = MessageToDict(message, preserving_proto_field_name=True)
206
+
207
+ remote_config = proto_dict.get("remote_config")
208
+ if not remote_config:
209
+ raise ValueError("No 'remote_config' field found in the provided message.")
210
+
211
+ config_map = remote_config.get("config", {}).get("config_map", {})
212
+ if not config_map:
213
+ raise ValueError("No 'config_map' found inside 'remote_config'.")
214
+
215
+ decoded_map = {}
216
+
217
+ for key, item in config_map.items():
218
+ body_base64 = item.get("body")
219
+ content_type = item.get("content_type")
220
+
221
+ if not body_base64:
222
+ decoded_map[key] = None
223
+ continue
224
+
225
+ try:
226
+ body_bytes = base64.b64decode(body_base64)
227
+ except Exception as e:
228
+ raise ValueError(f"Failed to base64 decode body for key {key}: {e}")
229
+
230
+ if content_type == "application/json":
231
+ try:
232
+ body_json = json.loads(body_bytes.decode('utf-8'))
233
+ decoded_map[key] = body_json
234
+ except Exception as e:
235
+ raise ValueError(f"Failed to JSON-decode body for key {key}: {e}")
236
+ else:
237
+ # If not JSON, just keep it as raw bytes
238
+ decoded_map[key] = body_bytes
239
+
240
+ inner = decoded_map.get("", None)
241
+ if inner is None:
242
+ return Config() # Return default values for config
243
+ return from_dict(Config, inner)
244
+
172
245
  def send_heartbeat(self) -> opamp_pb2.ServerToAgent: # type: ignore
173
- # opamp_logger.debug("Sending heartbeat to OpAMP server...")
246
+ # opamp_logger.debug("Sending heartbeat to OpAMP server...")
174
247
  try:
175
248
  agent_to_server = opamp_pb2.AgentToServer(remote_config_status=self.remote_config_status)
176
249
  return self.send_agent_to_server_message(agent_to_server)
@@ -179,23 +252,23 @@ class OpAMPHTTPClient:
179
252
  pass
180
253
 
181
254
  def get_agent_description(self) -> opamp_pb2.AgentDescription: # type: ignore
182
- # The "DISABLE_OPAMP_CLIENT" environment variable is defined only in our VMs environments.
255
+ # The "DISABLE_OPAMP_CLIENT" environment variable is defined only in our VMs environments.
183
256
  # Here we use it exclusively to distinguish between virtual machine and Kubernetes environments.
184
257
  #
185
- # - If "DISABLE_OPAMP_CLIENT" is set to "true", it indicates that the service is running in a VM,
258
+ # - If "DISABLE_OPAMP_CLIENT" is set to "true", it indicates that the service is running in a VM,
186
259
  # so we use "PROCESS_PID" for identification.
187
- # - Otherwise, the service is assumed to be running in a K8s environment,
260
+ # - Otherwise, the service is assumed to be running in a K8s environment,
188
261
  # and we use "PROCESS_VPID" instead.
189
262
  #
190
263
  # This ensures the correct process identification mechanism is applied based on the runtime environment.
191
264
 
192
265
  # Add additional attributes from environment variables
193
-
266
+
194
267
  if os.getenv("DISABLE_OPAMP_CLIENT", "false").strip().lower() == "true":
195
268
  process_id_key = ResourceAttributes.PROCESS_PID
196
269
  else:
197
270
  process_id_key = PROCESS_VPID
198
-
271
+
199
272
  identifying_attributes = [
200
273
  anyvalue_pb2.KeyValue(
201
274
  key=ResourceAttributes.SERVICE_INSTANCE_ID,
@@ -221,15 +294,15 @@ class OpAMPHTTPClient:
221
294
  value=anyvalue_pb2.AnyValue(string_value=value)
222
295
  )
223
296
  )
224
-
297
+
225
298
  return opamp_pb2.AgentDescription(
226
299
  identifying_attributes=identifying_attributes,
227
300
  non_identifying_attributes=[]
228
301
  )
229
-
302
+
230
303
  def get_agent_disconnect(self) -> opamp_pb2.AgentDisconnect: # type: ignore
231
304
  return opamp_pb2.AgentDisconnect()
232
-
305
+
233
306
  def get_agent_health(self, component_health: bool = None, last_error : str = None, status: str = None) -> opamp_pb2.ComponentHealth: # type: ignore
234
307
  health = opamp_pb2.ComponentHealth(
235
308
  )
@@ -239,24 +312,24 @@ class OpAMPHTTPClient:
239
312
  health.last_error = last_error
240
313
  if status is not None:
241
314
  health.status = status
242
-
315
+
243
316
  return health
244
-
245
-
317
+
318
+
246
319
  def send_agent_to_server_message(self, message: opamp_pb2.AgentToServer) -> opamp_pb2.ServerToAgent: # type: ignore
247
-
320
+
248
321
  message.instance_uid = self.instance_uid.encode('utf-8')
249
- message.sequence_num = self.next_sequence_num
322
+ message.sequence_num = self.next_sequence_num
250
323
  if self.remote_config_status:
251
324
  message.remote_config_status.CopyFrom(self.remote_config_status)
252
-
325
+
253
326
  self.next_sequence_num += 1
254
327
  message_bytes = message.SerializeToString()
255
-
328
+
256
329
  headers = {
257
330
  "Content-Type": "application/x-protobuf",
258
331
  }
259
-
332
+
260
333
  try:
261
334
  agent_message = attach(set_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY, True))
262
335
  response = requests_odigos.post(self.server_url, data=message_bytes, headers=headers, timeout=5)
@@ -269,7 +342,7 @@ class OpAMPHTTPClient:
269
342
  return opamp_pb2.ServerToAgent()
270
343
  finally:
271
344
  detach(agent_message)
272
-
345
+
273
346
  server_to_agent = opamp_pb2.ServerToAgent()
274
347
  try:
275
348
  server_to_agent.ParseFromString(response.content)
@@ -277,19 +350,19 @@ class OpAMPHTTPClient:
277
350
  # opamp_logger.error(f"Error parsing response from OpAMP server: {e}")
278
351
  return opamp_pb2.ServerToAgent()
279
352
  return server_to_agent
280
-
353
+
281
354
  def mandatory_env_vars_set(self):
282
355
  mandatory_env_vars = {
283
356
  "ODIGOS_OPAMP_SERVER_HOST": self.server_host,
284
357
  }
285
-
358
+
286
359
  for env_var, value in mandatory_env_vars.items():
287
360
  if not value:
288
361
  # opamp_logger.error(f"{env_var} environment variable not set")
289
362
  return False
290
-
291
- return True
292
-
363
+
364
+ return True
365
+
293
366
  def shutdown(self, custom_failure_message: str = None, component_health: bool = False):
294
367
  self.running = False
295
368
  # opamp_logger.info("Sending agent disconnect message to OpAMP server...")
@@ -298,22 +371,24 @@ class OpAMPHTTPClient:
298
371
  else:
299
372
  agent_health = self.get_agent_health(component_health=component_health, last_error="Python runtime is exiting", status=AgentHealthStatus.TERMINATED.value)
300
373
  disconnect_message = opamp_pb2.AgentToServer(agent_disconnect=opamp_pb2.AgentDisconnect(), health=agent_health)
301
-
374
+
302
375
  with self.condition:
303
376
  self.condition.notify_all()
304
377
  self.client_thread.join()
305
-
378
+
306
379
  self.send_agent_to_server_message(disconnect_message)
307
-
380
+
308
381
  def update_remote_config_status(self, server_to_agent: opamp_pb2.ServerToAgent) -> bool: # type: ignore
309
382
  if server_to_agent.HasField("remote_config"):
310
383
  remote_config_hash = server_to_agent.remote_config.config_hash
311
384
  remote_config_status = opamp_pb2.RemoteConfigStatus(last_remote_config_hash=remote_config_hash)
312
385
  self.remote_config_status = remote_config_status
313
386
  return True
314
-
315
387
  return False
316
388
 
389
+ def register_config_update_cb(self, cb) -> None:
390
+ self.update_conf_cb = cb
391
+
317
392
 
318
393
  # Mock client class for non-OpAMP installations
319
394
  # This class simulates the OpAMP client when the OpAMP server is not available.
@@ -325,4 +400,4 @@ class MockOpAMPClient:
325
400
  event.set()
326
401
 
327
402
  def shutdown(self, custom_failure_message=None):
328
- pass
403
+ pass
@@ -12,28 +12,28 @@ def get_sdk_config(config_map):
12
12
  return json.loads(config_map["SDK"].body)
13
13
  except json.JSONDecodeError:
14
14
  return {}
15
-
15
+
16
16
 
17
17
  def parse_first_message_to_resource_attributes(sdk_config, logger: logging.Logger) -> dict: # type: ignore
18
18
  '''
19
19
  Parses remote resource attributes from the SDK config.
20
20
  '''
21
-
21
+
22
22
  remote_resource_attributes = sdk_config.get('remoteResourceAttributes', [])
23
-
23
+
24
24
  if not remote_resource_attributes:
25
25
  # logger.error('missing "remoteResourceAttributes" section in OpAMP server remote config on first server to agent message')
26
26
  return {}
27
-
27
+
28
28
  return {item['key']: item['value'] for item in remote_resource_attributes}
29
29
 
30
30
  def parse_first_message_signals(sdk_config, logger: logging.Logger): # type: ignore
31
31
  """
32
32
  Parses the trace, logs, and metrics signals configuration from the SDK config.
33
33
  """
34
-
34
+
35
35
  return {
36
36
  "traceSignal": sdk_config.get("traceSignal", {}).get("enabled", False),
37
37
  "metricsSignal": sdk_config.get("metricsSignal", {}).get("enabled", False),
38
38
  "logsSignal": sdk_config.get("logsSignal", {}).get("enabled", False),
39
- }
39
+ }
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="odigos-opentelemetry-python",
5
- version="1.0.45",
5
+ version="1.0.46",
6
6
  description="Odigos Initializer for Python OpenTelemetry Components",
7
7
  author="Tamir David",
8
8
  author_email="tamir@odigos.io",