dt-extensions-sdk 1.6.3__py3-none-any.whl → 1.7.2__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dt-extensions-sdk
3
- Version: 1.6.3
3
+ Version: 1.7.2
4
4
  Project-URL: Documentation, https://github.com/dynatrace-extensions/dt-extensions-python-sdk#readme
5
5
  Project-URL: Issues, https://github.com/dynatrace-extensions/dt-extensions-python-sdk/issues
6
6
  Project-URL: Source, https://github.com/dynatrace-extensions/dt-extensions-python-sdk
@@ -1,5 +1,5 @@
1
- dynatrace_extension/__about__.py,sha256=eNoHOwIg_u9DD1bJ1s9mAK6ZEtFzjKYT4HvhSt3PX94,110
2
- dynatrace_extension/__init__.py,sha256=fe3nw1UVecc0cGu9R7J82V1A0n7c-Z_Z4C_LuNXU6WU,813
1
+ dynatrace_extension/__about__.py,sha256=qRNIJuMF_5DRmYm6b4aociCQcAdhCKOCXEIN81aEsPQ,110
2
+ dynatrace_extension/__init__.py,sha256=MJNJYCFWLEwPmBLoETWFZddyUCMDgZfKkRycmmGM_w4,806
3
3
  dynatrace_extension/cli/__init__.py,sha256=HCboY_eJPoqjFmoPDsBL8Jk6aNvank8K7JpkVrgwzUM,123
4
4
  dynatrace_extension/cli/main.py,sha256=OTjJ4XHJvvYXj10a7WFFHVNnkyECPg1ClW6Os8piN8k,20168
5
5
  dynatrace_extension/cli/schema.py,sha256=d8wKUodRiaU3hfSZDWVNpD15lBfhmif2oQ-k07IxcaA,3230
@@ -17,21 +17,22 @@ dynatrace_extension/cli/create/extension_template/extension_name/__init__.py.tem
17
17
  dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template,sha256=cS79GVxJB-V-gocu4ZOjmZ54HXJNg89eXdLf89zDHJQ,1249
18
18
  dynatrace_extension/sdk/__init__.py,sha256=RsqQ1heGyCmSK3fhuEKAcxQIRCg4gEK0-eSkIehL5Nc,86
19
19
  dynatrace_extension/sdk/activation.py,sha256=KIoPWMZs3tKiMG8XhCfeNgRlz2vxDKcAASgSACcEfIQ,1456
20
- dynatrace_extension/sdk/callback.py,sha256=K2jUacVU5FJZaeZgZPOMVLhXr_L39Sz_munYeMMm48w,6688
21
- dynatrace_extension/sdk/communication.py,sha256=Kbar_SpeIJInQZ8oYXhuH6ZknZX89wfHKJQ2LevhFSw,23280
20
+ dynatrace_extension/sdk/callback.py,sha256=woumpcWID09QHGc_rSrukNslVG8Qo0UQEgv8VTB6m7c,6681
21
+ dynatrace_extension/sdk/communication.py,sha256=uTSURmgSHit2N1hHUc3-yKmEBVMHi6hDBrdb1EaCAsE,18419
22
22
  dynatrace_extension/sdk/event.py,sha256=J261imbFKpxfuAQ6Nfu3RRcsIQKKivy6fme1nww2g-8,388
23
- dynatrace_extension/sdk/extension.py,sha256=WnC6VR3aixivaOgOJvokZTZEoQcEfOjelhJlH4wiuDo,48041
23
+ dynatrace_extension/sdk/extension.py,sha256=8P0ANxF3WVjVeGAA73MXTeSekpL1MarEBB6ZoKGnpZY,49669
24
24
  dynatrace_extension/sdk/helper.py,sha256=m4gGHtIKYkfANC2MOGdxKUZlmH5tnZO6WTNqll27lyY,6476
25
25
  dynatrace_extension/sdk/metric.py,sha256=-kq7JWpk7UGvcjqafTt-o6k4urwhsGVXmnuQg7Sf9PQ,3622
26
26
  dynatrace_extension/sdk/runtime.py,sha256=7bC4gUJsVSHuL_E7r2EWrne95nm1BjZiMGkyNqA7ZCU,2796
27
27
  dynatrace_extension/sdk/snapshot.py,sha256=LnWVCtCK4NIEV3_kX-ly_LGHpNBSeErtsxCI1PH3L28,7521
28
+ dynatrace_extension/sdk/status.py,sha256=fE0qCGaanV7Ss1144p-dLX9uJMKIb0Rs6A0pLZLPlrI,8599
28
29
  dynatrace_extension/sdk/throttled_logger.py,sha256=JXDiHh8syl8R0gJ-wfxmmBqvGCBMQX4pxPkxscaCsXo,3292
29
30
  dynatrace_extension/sdk/vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
31
  dynatrace_extension/sdk/vendor/mureq/LICENSE,sha256=8AVcgZgiT_mvK1fOofXtRRr2f1dRXS_K21NuxQgP4VM,671
31
32
  dynatrace_extension/sdk/vendor/mureq/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
33
  dynatrace_extension/sdk/vendor/mureq/mureq.py,sha256=znF4mvzk5L03CLNozRz8UpK-fMijmSkObDFwlbhwLUg,14656
33
- dt_extensions_sdk-1.6.3.dist-info/METADATA,sha256=6NAl-06OQM3SBqBYWRJwxaPq0gWfZEmaf2JILwRPNSw,2721
34
- dt_extensions_sdk-1.6.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
35
- dt_extensions_sdk-1.6.3.dist-info/entry_points.txt,sha256=pweyOCgENGHjOlT6_kXYaBPOrE3p18K0UettqnNlnoE,55
36
- dt_extensions_sdk-1.6.3.dist-info/licenses/LICENSE.txt,sha256=3Zihv0lOVYHNfDkJC-tUAU6euP9r2NexsDW4w-zqgVk,1078
37
- dt_extensions_sdk-1.6.3.dist-info/RECORD,,
34
+ dt_extensions_sdk-1.7.2.dist-info/METADATA,sha256=FVuIQEkut5SwCh77eZrb92ngUurYD_LxUhFZab6Diw8,2721
35
+ dt_extensions_sdk-1.7.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
36
+ dt_extensions_sdk-1.7.2.dist-info/entry_points.txt,sha256=pweyOCgENGHjOlT6_kXYaBPOrE3p18K0UettqnNlnoE,55
37
+ dt_extensions_sdk-1.7.2.dist-info/licenses/LICENSE.txt,sha256=3Zihv0lOVYHNfDkJC-tUAU6euP9r2NexsDW4w-zqgVk,1078
38
+ dt_extensions_sdk-1.7.2.dist-info/RECORD,,
@@ -3,4 +3,4 @@
3
3
  # SPDX-License-Identifier: MIT
4
4
 
5
5
 
6
- __version__ = "1.6.3"
6
+ __version__ = "1.7.2"
@@ -6,7 +6,6 @@
6
6
  # ruff: noqa: F401
7
7
 
8
8
  from .sdk.activation import ActivationConfig, ActivationType
9
- from .sdk.communication import EndpointStatus, EndpointStatuses, IgnoreStatus, MultiStatus, Status, StatusValue
10
9
  from .sdk.event import Severity
11
10
  from .sdk.extension import DtEventType, Extension
12
11
  from .sdk.helper import (
@@ -25,3 +24,4 @@ from .sdk.helper import (
25
24
  schedule_function,
26
25
  )
27
26
  from .sdk.metric import Metric, MetricType, SummaryStat
27
+ from .sdk.status import EndpointStatus, EndpointStatuses, IgnoreStatus, MultiStatus, Status, StatusValue
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta
9
9
  from timeit import default_timer as timer
10
10
 
11
11
  from .activation import ActivationType
12
- from .communication import EndpointStatuses, IgnoreStatus, MultiStatus, Status, StatusValue
12
+ from .status import EndpointStatuses, IgnoreStatus, MultiStatus, Status, StatusValue
13
13
 
14
14
 
15
15
  class WrappedCallback:
@@ -10,11 +10,10 @@ import sys
10
10
  from abc import ABC, abstractmethod
11
11
  from collections.abc import Generator, Sequence
12
12
  from dataclasses import dataclass
13
- from enum import Enum
14
13
  from pathlib import Path
15
- from threading import RLock
16
14
  from typing import Any, TypeVar
17
15
 
16
+ from .status import Status
18
17
  from .vendor.mureq.mureq import HTTPException, Response, request
19
18
 
20
19
  CONTENT_TYPE_JSON = "application/json;charset=utf-8"
@@ -30,173 +29,6 @@ MAX_METRIC_REQUEST_SIZE = 1_000_000 # actually 1_048_576
30
29
  HTTP_BAD_REQUEST = 400
31
30
 
32
31
 
33
- class StatusValue(Enum):
34
- EMPTY = ""
35
- OK = "OK"
36
- GENERIC_ERROR = "GENERIC_ERROR"
37
- INVALID_ARGS_ERROR = "INVALID_ARGS_ERROR"
38
- EEC_CONNECTION_ERROR = "EEC_CONNECTION_ERROR"
39
- INVALID_CONFIG_ERROR = "INVALID_CONFIG_ERROR"
40
- AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
41
- DEVICE_CONNECTION_ERROR = "DEVICE_CONNECTION_ERROR"
42
- WARNING = "WARNING"
43
- UNKNOWN_ERROR = "UNKNOWN_ERROR"
44
-
45
-
46
- class IgnoreStatus:
47
- pass
48
-
49
-
50
- class Status:
51
- def __init__(self, status: StatusValue = StatusValue.EMPTY, message: str = "", timestamp: int | None = None):
52
- self.status = status
53
- self.message = message
54
- self.timestamp = timestamp
55
-
56
- def to_json(self) -> dict:
57
- status = {"status": self.status.value, "message": self.message}
58
- if self.timestamp:
59
- status["timestamp"] = self.timestamp # type: ignore
60
- return status
61
-
62
- def __repr__(self):
63
- return json.dumps(self.to_json())
64
-
65
- def is_error(self) -> bool:
66
- # WARNING is treated as an error
67
- return self.status not in (StatusValue.OK, StatusValue.EMPTY)
68
-
69
- def is_warning(self) -> bool:
70
- return self.status == StatusValue.WARNING
71
-
72
-
73
- class MultiStatus:
74
- def __init__(self):
75
- self.statuses: list[Status] = []
76
-
77
- def add_status(self, status: StatusValue, message):
78
- self.statuses.append(Status(status, message))
79
-
80
- def build(self) -> Status:
81
- ret = Status(StatusValue.OK)
82
- if len(self.statuses) == 0:
83
- return ret
84
-
85
- messages = []
86
- all_ok = True
87
- all_err = True
88
- any_warning = False
89
-
90
- for stored_status in self.statuses:
91
- if stored_status.message != "":
92
- messages.append(stored_status.message)
93
-
94
- if stored_status.is_warning():
95
- any_warning = True
96
-
97
- if stored_status.is_error():
98
- all_ok = False
99
- else:
100
- all_err = False
101
-
102
- ret.message = ", ".join(messages)
103
-
104
- if any_warning:
105
- ret.status = StatusValue.WARNING
106
- elif all_ok:
107
- ret.status = StatusValue.OK
108
- elif all_err:
109
- ret.status = StatusValue.GENERIC_ERROR
110
- else:
111
- ret.status = StatusValue.WARNING
112
-
113
- return ret
114
-
115
-
116
- class EndpointStatus:
117
- def __init__(self, endpoint_hint: str, short_status: StatusValue, message: str):
118
- self.endpoint = endpoint_hint
119
- self.status: StatusValue = short_status
120
- self.message = message
121
-
122
- def __str__(self):
123
- return str(self.__dict__)
124
-
125
-
126
- class EndpointStatuses:
127
- class TooManyEndpointStatusesError(Exception):
128
- pass
129
-
130
- class MergeConflictError(Exception):
131
- def __init__(self, first: EndpointStatus, second: EndpointStatus):
132
- super().__init__(f"Endpoint Statuses conflict while merging - first: {first}; second: {second}")
133
-
134
- def __init__(self, total_endpoints_number: int):
135
- self._lock = RLock()
136
- self._faulty_endpoints: dict[str, EndpointStatus] = {}
137
- self._num_endpoints = total_endpoints_number
138
-
139
- def add_endpoint_status(self, status: EndpointStatus):
140
- with self._lock:
141
- if status.status == StatusValue.OK:
142
- self.clear_endpoint_error(status.endpoint)
143
- else:
144
- if len(self._faulty_endpoints) == self._num_endpoints:
145
- message = "Cannot add another endpoint status. \
146
- The number of reported statuses already has reached preconfigured maximum of {self._num_endpoints} endpoints."
147
- raise EndpointStatuses.TooManyEndpointStatusesError(message)
148
-
149
- self._faulty_endpoints[status.endpoint] = status
150
-
151
- def clear_endpoint_error(self, endpoint_hint: str):
152
- with self._lock:
153
- try:
154
- del self._faulty_endpoints[endpoint_hint]
155
- except KeyError:
156
- pass
157
-
158
- def merge(self, other: EndpointStatuses):
159
- with self._lock:
160
- with other._lock:
161
- self._num_endpoints += other._num_endpoints
162
-
163
- for endpoint, status in other._faulty_endpoints.items():
164
- if endpoint not in self._faulty_endpoints.keys():
165
- self._faulty_endpoints[endpoint] = status
166
- else:
167
- self._num_endpoints -= 1
168
- raise EndpointStatuses.MergeConflictError(
169
- self._faulty_endpoints[endpoint], other._faulty_endpoints[endpoint]
170
- )
171
-
172
- def build_common_status(self) -> Status:
173
- with self._lock:
174
- ok_count = self._num_endpoints - len(self._faulty_endpoints)
175
- nok_count = len(self._faulty_endpoints)
176
-
177
- if nok_count == 0:
178
- return Status(StatusValue.OK, f"Endpoints OK: {self._num_endpoints} NOK: 0")
179
-
180
- error_messages = []
181
- for ep_status in self._faulty_endpoints.values():
182
- error_messages.append(f"{ep_status.endpoint} - {ep_status.status.value} {ep_status.message}")
183
- common_msg = ", ".join(error_messages)
184
-
185
- # Determine status value
186
- all_endpoints_faulty = nok_count == self._num_endpoints
187
- has_warning_status = StatusValue.WARNING in [
188
- ep_status.status for ep_status in self._faulty_endpoints.values()
189
- ]
190
-
191
- if all_endpoints_faulty and not has_warning_status:
192
- status_value = StatusValue.GENERIC_ERROR
193
- else:
194
- status_value = StatusValue.WARNING
195
-
196
- message = f"Endpoints OK: {ok_count} NOK: {nok_count} NOK_reported_errors: {common_msg}"
197
- return Status(status=status_value, message=message)
198
-
199
-
200
32
  class CommunicationClient(ABC):
201
33
  """
202
34
  Abstract class for extension communication
@@ -250,6 +82,10 @@ class CommunicationClient(ABC):
250
82
  def send_dt_event(self, event: dict) -> None:
251
83
  pass
252
84
 
85
+ @abstractmethod
86
+ def send_sfm_logs(self, sfm_logs: dict | list[dict]) -> list[dict | None]:
87
+ pass
88
+
253
89
 
254
90
  class HttpClient(CommunicationClient):
255
91
  """
@@ -261,6 +97,7 @@ class HttpClient(CommunicationClient):
261
97
  self._extension_config_url = f"{base_url}/extconfig/{datasource_id}"
262
98
  self._metric_url = f"{base_url}/mint/{datasource_id}"
263
99
  self._sfm_url = f"{base_url}/sfm/{datasource_id}"
100
+ self._sfm_logs_url = f"{base_url}/sfmlogs/{datasource_id}"
264
101
  self._keep_alive_url = f"{base_url}/alive/{datasource_id}"
265
102
  self._timediff_url = f"{base_url}/timediffms"
266
103
  self._events_url = f"{base_url}/logs/{datasource_id}"
@@ -417,7 +254,13 @@ class HttpClient(CommunicationClient):
417
254
 
418
255
  def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> list[dict | None]:
419
256
  self.logger.debug(f"Sending log events: {events}")
257
+ return self._send_events(self._events_url, events, eec_enrichment)
258
+
259
+ def send_sfm_logs(self, sfm_logs: dict | list[dict]):
260
+ self.logger.debug(f"Sending SFM logs: {sfm_logs}")
261
+ return self._send_events(self._sfm_logs_url, sfm_logs)
420
262
 
263
+ def _send_events(self, url, events: dict | list[dict], eec_enrichment: bool = True) -> list[dict | None]:
421
264
  responses = []
422
265
  if isinstance(events, dict):
423
266
  events = [events]
@@ -426,7 +269,7 @@ class HttpClient(CommunicationClient):
426
269
  for batch in batches:
427
270
  try:
428
271
  eec_response = self._make_request(
429
- self._events_url,
272
+ url,
430
273
  "POST",
431
274
  batch,
432
275
  extra_headers={"Content-Type": CONTENT_TYPE_JSON, "eec-enrichment": str(eec_enrichment).lower()},
@@ -500,7 +343,7 @@ class DebugClient(CommunicationClient):
500
343
  def get_feature_sets(self) -> dict[str, list[str]]:
501
344
  # This is only called from dt-sdk run, where PyYaml is installed because of dt-cli
502
345
  # Do NOT move this to the top of the file
503
- import yaml # type: ignore
346
+ import yaml # noqa: PLC0415
504
347
 
505
348
  # Grab the feature sets from the extension.yaml file
506
349
  extension_yaml = yaml.safe_load(self.extension_config)
@@ -583,6 +426,17 @@ class DebugClient(CommunicationClient):
583
426
 
584
427
  return activation_config_string
585
428
 
429
+ def send_sfm_logs(self, sfm_logs: dict | list[dict]) -> list[dict | None]:
430
+ if isinstance(sfm_logs, dict):
431
+ sfm_logs = [sfm_logs]
432
+
433
+ self.logger.info(f"send_sfm_logs: {len(sfm_logs)} logs")
434
+
435
+ if self.print_metrics:
436
+ for log in sfm_logs:
437
+ self.logger.info(f"send_sfm_log: {log}")
438
+ return []
439
+
586
440
 
587
441
  def divide_into_batches(
588
442
  items: Sequence[dict | str], max_size_bytes: int, join_with: str | None = None
@@ -20,19 +20,12 @@ from typing import Any, ClassVar, NamedTuple
20
20
 
21
21
  from .activation import ActivationConfig, ActivationType
22
22
  from .callback import WrappedCallback
23
- from .communication import (
24
- CommunicationClient,
25
- DebugClient,
26
- EndpointStatuses,
27
- HttpClient,
28
- IgnoreStatus,
29
- Status,
30
- StatusValue,
31
- )
23
+ from .communication import CommunicationClient, DebugClient, HttpClient
32
24
  from .event import Severity
33
25
  from .metric import Metric, MetricType, SfmMetric, SummaryStat
34
26
  from .runtime import RuntimeProperties
35
27
  from .snapshot import Snapshot
28
+ from .status import EndpointStatuses, EndpointStatusesMap, IgnoreStatus, Status, StatusValue
36
29
  from .throttled_logger import StrictThrottledHandler, ThrottledHandler
37
30
 
38
31
  HEARTBEAT_INTERVAL = timedelta(seconds=50)
@@ -191,7 +184,7 @@ class Extension:
191
184
 
192
185
  def __new__(cls, *args, **kwargs): # noqa: ARG004
193
186
  if Extension._instance is None:
194
- Extension._instance = super(__class__, cls).__new__(cls)
187
+ Extension._instance = super().__new__(cls)
195
188
  return Extension._instance
196
189
 
197
190
  def __init__(self, name: str = "") -> None:
@@ -285,6 +278,13 @@ class Extension:
285
278
  # Error message from caught exception in self.initialize()
286
279
  self._initialization_error: str = ""
287
280
 
281
+ # Map of all Endpoint Statuses
282
+ self._sfm_logs_allowed = not self.extension_name.startswith("custom:")
283
+ if not self._sfm_logs_allowed:
284
+ self.logger.warning("SFM logs not allowed for custom extensions.")
285
+
286
+ self._ep_statuses = EndpointStatusesMap(send_sfm_logs_function=self._send_sfm_logs)
287
+
288
288
  self._parse_args()
289
289
 
290
290
  for function, interval, args, activation_type in Extension.schedule_decorators:
@@ -412,7 +412,7 @@ class Extension:
412
412
  else:
413
413
  self._schedule_callback(callback)
414
414
 
415
- def query(self):
415
+ def query(self) -> Any:
416
416
  """Callback to be executed every minute by default.
417
417
 
418
418
  Optional method that can be implemented by subclasses.
@@ -683,7 +683,7 @@ class Extension:
683
683
  msg = f"Event type must be a DtEventType enum value, got: {value}"
684
684
  raise ValueError(msg)
685
685
  if key == "properties":
686
- for prop_key, prop_val in event[key].items():
686
+ for prop_key, prop_val in value.items():
687
687
  if not isinstance(prop_key, str) or not isinstance(prop_val, str):
688
688
  msg = f'invalid "properties" member: {prop_key}: {prop_val}, required: "str": str'
689
689
  raise ValueError(msg)
@@ -1037,8 +1037,7 @@ class Extension:
1037
1037
  if internal_callback_error:
1038
1038
  return Status(overall_status_value, "\n".join(messages))
1039
1039
 
1040
- # Handle regular statuses, merge all EndpointStatuses
1041
- ep_status_merged = EndpointStatuses(0)
1040
+ # Handle regular statuses, report all EndpointStatuses
1042
1041
  all_ok = True
1043
1042
  all_err = True
1044
1043
  any_warning = False
@@ -1048,10 +1047,7 @@ class Extension:
1048
1047
  continue
1049
1048
 
1050
1049
  if isinstance(callback.status, EndpointStatuses):
1051
- try:
1052
- ep_status_merged.merge(callback.status)
1053
- except EndpointStatuses.MergeConflictError as e:
1054
- self.logger.exception(e)
1050
+ self._ep_statuses.update_ep_statuses(callback.status)
1055
1051
  continue
1056
1052
 
1057
1053
  if callback.status.is_warning():
@@ -1066,8 +1062,9 @@ class Extension:
1066
1062
  messages.append(f"{callback.name()}: {callback.status.status.value} - {callback.status.message}")
1067
1063
 
1068
1064
  # Handle merged EndpointStatuses
1069
- if ep_status_merged._num_endpoints > 0:
1070
- ep_status_merged = ep_status_merged.build_common_status()
1065
+ if self._ep_statuses.contains_any_status():
1066
+ self._ep_statuses.send_ep_logs()
1067
+ ep_status_merged = self._ep_statuses.build_common_status()
1071
1068
  messages.insert(0, ep_status_merged.message)
1072
1069
 
1073
1070
  if ep_status_merged.is_warning():
@@ -1221,3 +1218,33 @@ class Extension:
1221
1218
  raise FileNotFoundError(msg)
1222
1219
 
1223
1220
  return Snapshot.parse_from_file(snapshot_file)
1221
+
1222
+ def _send_sfm_logs_internal(self, logs: dict | list[dict]):
1223
+ try:
1224
+ responses = self._client.send_sfm_logs(logs)
1225
+
1226
+ for response in responses:
1227
+ with self._internal_callbacks_results_lock:
1228
+ self._internal_callbacks_results[self._send_sfm_logs_internal.__name__] = Status(StatusValue.OK)
1229
+ if not response or "error" not in response or "message" not in response["error"]:
1230
+ return
1231
+ self._internal_callbacks_results[self._send_sfm_logs_internal.__name__] = Status(
1232
+ StatusValue.GENERIC_ERROR, response["error"]["message"]
1233
+ )
1234
+ except Exception as e:
1235
+ api_logger.error(f"Error sending SFM logs: {e!r}", exc_info=True)
1236
+ with self._internal_callbacks_results_lock:
1237
+ self._internal_callbacks_results[self._send_sfm_logs_internal.__name__] = Status(
1238
+ StatusValue.GENERIC_ERROR, str(e)
1239
+ )
1240
+
1241
+ def _send_sfm_logs(self, logs: dict | list[dict]):
1242
+ if not self._sfm_logs_allowed or not logs:
1243
+ return
1244
+
1245
+ for log in logs:
1246
+ log.update(self._metadata)
1247
+ log["dt.extension.config.label"] = self.monitoring_config_name
1248
+ log.pop("monitoring.configuration", None)
1249
+
1250
+ self._internal_executor.submit(self._send_sfm_logs_internal, logs)
@@ -0,0 +1,263 @@
1
+ import json
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta
5
+ from enum import Enum
6
+ from threading import Lock
7
+
8
+ from .event import Severity
9
+
10
+
11
+ class DynatraceDeprecatedError(Exception):
12
+ pass
13
+
14
+
15
+ class StatusValue(Enum):
16
+ EMPTY = ""
17
+ OK = "OK"
18
+ GENERIC_ERROR = "GENERIC_ERROR"
19
+ INVALID_ARGS_ERROR = "INVALID_ARGS_ERROR"
20
+ EEC_CONNECTION_ERROR = "EEC_CONNECTION_ERROR"
21
+ INVALID_CONFIG_ERROR = "INVALID_CONFIG_ERROR"
22
+ AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
23
+ DEVICE_CONNECTION_ERROR = "DEVICE_CONNECTION_ERROR"
24
+ WARNING = "WARNING"
25
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
26
+
27
+ def is_error(self) -> bool:
28
+ # WARNING is treated as an error
29
+ return self not in (StatusValue.OK, StatusValue.EMPTY)
30
+
31
+ def is_warning(self) -> bool:
32
+ return self == StatusValue.WARNING
33
+
34
+
35
+ class IgnoreStatus:
36
+ pass
37
+
38
+
39
+ class Status:
40
+ def __init__(self, status: StatusValue = StatusValue.EMPTY, message: str = "", timestamp: int | None = None):
41
+ self.status = status
42
+ self.message = message
43
+ self.timestamp = timestamp
44
+
45
+ def to_json(self) -> dict:
46
+ status = {"status": self.status.value, "message": self.message}
47
+ if self.timestamp:
48
+ status["timestamp"] = self.timestamp # type: ignore
49
+ return status
50
+
51
+ def __repr__(self):
52
+ return json.dumps(self.to_json())
53
+
54
+ def is_error(self) -> bool:
55
+ # WARNING is treated as an error
56
+ return self.status.is_error()
57
+
58
+ def is_warning(self) -> bool:
59
+ return self.status.is_warning()
60
+
61
+
62
+ class MultiStatus:
63
+ def __init__(self) -> None:
64
+ self.statuses: list[Status] = []
65
+
66
+ def add_status(self, status: StatusValue, message):
67
+ self.statuses.append(Status(status, message))
68
+
69
+ def build(self) -> Status:
70
+ ret = Status(StatusValue.OK)
71
+ if len(self.statuses) == 0:
72
+ return ret
73
+
74
+ messages = []
75
+ all_ok = True
76
+ all_err = True
77
+ any_warning = False
78
+
79
+ for stored_status in self.statuses:
80
+ if stored_status.message != "":
81
+ messages.append(stored_status.message)
82
+
83
+ if stored_status.is_warning():
84
+ any_warning = True
85
+
86
+ if stored_status.is_error():
87
+ all_ok = False
88
+ else:
89
+ all_err = False
90
+
91
+ ret.message = ", ".join(messages)
92
+
93
+ if any_warning:
94
+ ret.status = StatusValue.WARNING
95
+ elif all_ok:
96
+ ret.status = StatusValue.OK
97
+ elif all_err:
98
+ ret.status = StatusValue.GENERIC_ERROR
99
+ else:
100
+ ret.status = StatusValue.WARNING
101
+
102
+ return ret
103
+
104
+
105
+ class EndpointStatus:
106
+ def __init__(self, endpoint_hint: str, short_status: StatusValue, message: str | None = None):
107
+ self.endpoint = endpoint_hint
108
+ self.status: StatusValue = short_status
109
+ self.message = message
110
+
111
+ def __repr__(self):
112
+ return str(self.__dict__)
113
+
114
+ def __eq__(self, other):
115
+ return isinstance(other, EndpointStatus) and self.__dict__ == other.__dict__
116
+
117
+ def __hash__(self):
118
+ return hash(tuple(sorted(self.__dict__.items())))
119
+
120
+
121
+ class EndpointStatuses:
122
+ def __init__(self, total_endpoints_number=None) -> None:
123
+ if total_endpoints_number is not None:
124
+ msg = (
125
+ "EndpointStatuses.__init__: usage of `total_endpoints_number` parameter is abandoned. "
126
+ "Use other class methods to explicitly report all status changes for any endpoint."
127
+ )
128
+ raise DynatraceDeprecatedError(msg)
129
+
130
+ self._lock = Lock()
131
+ self._endpoints_statuses: dict[str, EndpointStatus] = {}
132
+
133
+ def add_endpoint_status(self, status: EndpointStatus):
134
+ with self._lock:
135
+ self._endpoints_statuses[status.endpoint] = status
136
+
137
+
138
+ class StatusState(Enum):
139
+ INITIAL = "INITIAL"
140
+ NEW = "NEW"
141
+ ONGOING = "ONGOING"
142
+
143
+
144
+ @dataclass
145
+ class EndpointStatusRecord:
146
+ ep_status: EndpointStatus
147
+ last_sent: datetime | None
148
+ state: StatusState
149
+
150
+ def __repr__(self):
151
+ return str(self.__dict__)
152
+
153
+
154
+ class EndpointStatusesMap:
155
+ RESENDING_INTERVAL = timedelta(hours=2)
156
+
157
+ def __init__(self, send_sfm_logs_function: Callable) -> None:
158
+ self._lock = Lock()
159
+ self._ep_records: dict[str, EndpointStatusRecord] = {}
160
+ self._send_sfm_logs_function = send_sfm_logs_function
161
+ self._logs_to_send: list[str] = []
162
+
163
+ def contains_any_status(self) -> bool:
164
+ return len(self._ep_records) > 0
165
+
166
+ def update_ep_statuses(self, new_ep_statuses: EndpointStatuses):
167
+ with self._lock:
168
+ with new_ep_statuses._lock:
169
+ for endpoint, ep_status in new_ep_statuses._endpoints_statuses.items():
170
+ if endpoint not in self._ep_records.keys():
171
+ self._ep_records[endpoint] = EndpointStatusRecord(
172
+ ep_status=ep_status, last_sent=None, state=StatusState.INITIAL
173
+ )
174
+ elif ep_status != self._ep_records[endpoint].ep_status:
175
+ self._ep_records[endpoint] = EndpointStatusRecord(
176
+ ep_status=ep_status, last_sent=None, state=StatusState.NEW
177
+ )
178
+
179
+ def send_ep_logs(self):
180
+ logs_to_send = []
181
+
182
+ with self._lock:
183
+ for ep_record in self._ep_records.values():
184
+ if self._should_be_reported(ep_record):
185
+ logs_to_send.append(
186
+ self._prepare_ep_status_log(
187
+ ep_record.ep_status.endpoint,
188
+ ep_record.state,
189
+ ep_record.ep_status.status,
190
+ ep_record.ep_status.message,
191
+ )
192
+ )
193
+ ep_record.last_sent = datetime.now()
194
+ ep_record.state = StatusState.ONGOING
195
+
196
+ if logs_to_send:
197
+ self._send_sfm_logs_function(logs_to_send)
198
+
199
+ def _should_be_reported(self, ep_record: EndpointStatusRecord):
200
+ if ep_record.ep_status.status == StatusValue.OK:
201
+ return ep_record.state == StatusState.NEW
202
+ elif ep_record.state in (StatusState.INITIAL, StatusState.NEW):
203
+ return True
204
+ elif ep_record.state == StatusState.ONGOING and (
205
+ ep_record.last_sent is None or datetime.now() - ep_record.last_sent >= self.RESENDING_INTERVAL
206
+ ):
207
+ return True
208
+ else:
209
+ return False
210
+
211
+ def _prepare_ep_status_log(
212
+ self, endpoint_name: str, prefix: StatusState, status_value: StatusValue, status_message: str
213
+ ) -> dict:
214
+ level = Severity.ERROR.value
215
+
216
+ if status_value.is_error() is False:
217
+ level = Severity.INFO.value
218
+ elif status_value.is_warning():
219
+ level = Severity.WARN.value
220
+
221
+ ep_status_log = {
222
+ "device.address": endpoint_name,
223
+ "level": level,
224
+ "message": f"{endpoint_name}: [{prefix.value}] - {status_value.value} {status_message}",
225
+ }
226
+
227
+ return ep_status_log
228
+
229
+ def build_common_status(self) -> Status:
230
+ with self._lock:
231
+ # Summarize all statuses
232
+ ok_count = 0
233
+ warning_count = 0
234
+ error_count = 0
235
+ messages_to_report = []
236
+
237
+ for ep_record in self._ep_records.values():
238
+ ep_status = ep_record.ep_status
239
+
240
+ if ep_status.status.is_warning():
241
+ warning_count += 1
242
+ messages_to_report.append(f"{ep_status.endpoint} - {ep_status.status.value} {ep_status.message}")
243
+ elif ep_status.status.is_error():
244
+ error_count += 1
245
+ messages_to_report.append(f"{ep_status.endpoint} - {ep_status.status.value} {ep_status.message}")
246
+ else:
247
+ ok_count += 1
248
+
249
+ status_msg = f"Endpoints OK: {ok_count} WARNING: {warning_count} ERROR: {error_count}"
250
+
251
+ # Early return if all OK
252
+ if error_count == 0 and warning_count == 0:
253
+ return Status(StatusValue.OK, status_msg)
254
+
255
+ # Build final status if some errors present
256
+ status_msg += f" Unhealthy endpoints: {', '.join(messages_to_report)}"
257
+
258
+ if ok_count == 0 and warning_count == 0:
259
+ status_value = StatusValue.GENERIC_ERROR
260
+ else:
261
+ status_value = StatusValue.WARNING
262
+
263
+ return Status(status=status_value, message=status_msg)