dt-extensions-sdk 1.1.11__py3-none-any.whl → 1.1.13__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.
Files changed (31) hide show
  1. {dt_extensions_sdk-1.1.11.dist-info → dt_extensions_sdk-1.1.13.dist-info}/METADATA +1 -1
  2. dt_extensions_sdk-1.1.13.dist-info/RECORD +33 -0
  3. {dt_extensions_sdk-1.1.11.dist-info → dt_extensions_sdk-1.1.13.dist-info}/WHEEL +1 -1
  4. {dt_extensions_sdk-1.1.11.dist-info → dt_extensions_sdk-1.1.13.dist-info}/licenses/LICENSE.txt +9 -9
  5. dynatrace_extension/__about__.py +5 -4
  6. dynatrace_extension/__init__.py +27 -27
  7. dynatrace_extension/cli/__init__.py +5 -5
  8. dynatrace_extension/cli/create/__init__.py +1 -1
  9. dynatrace_extension/cli/create/create.py +76 -76
  10. dynatrace_extension/cli/create/extension_template/.gitignore.template +160 -160
  11. dynatrace_extension/cli/create/extension_template/README.md.template +33 -33
  12. dynatrace_extension/cli/create/extension_template/activation.json.template +15 -15
  13. dynatrace_extension/cli/create/extension_template/extension/activationSchema.json.template +118 -118
  14. dynatrace_extension/cli/create/extension_template/extension/extension.yaml.template +16 -16
  15. dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template +43 -43
  16. dynatrace_extension/cli/create/extension_template/setup.py.template +12 -12
  17. dynatrace_extension/cli/main.py +428 -416
  18. dynatrace_extension/cli/schema.py +129 -129
  19. dynatrace_extension/sdk/__init__.py +3 -3
  20. dynatrace_extension/sdk/activation.py +43 -43
  21. dynatrace_extension/sdk/callback.py +141 -141
  22. dynatrace_extension/sdk/communication.py +469 -454
  23. dynatrace_extension/sdk/event.py +19 -19
  24. dynatrace_extension/sdk/extension.py +1037 -1034
  25. dynatrace_extension/sdk/helper.py +191 -191
  26. dynatrace_extension/sdk/metric.py +118 -118
  27. dynatrace_extension/sdk/runtime.py +67 -67
  28. dynatrace_extension/sdk/vendor/mureq/LICENSE +13 -13
  29. dynatrace_extension/sdk/vendor/mureq/mureq.py +447 -447
  30. dt_extensions_sdk-1.1.11.dist-info/RECORD +0 -33
  31. {dt_extensions_sdk-1.1.11.dist-info → dt_extensions_sdk-1.1.13.dist-info}/entry_points.txt +0 -0
@@ -1,454 +1,469 @@
1
- # SPDX-FileCopyrightText: 2023-present Dynatrace LLC
2
- #
3
- # SPDX-License-Identifier: MIT
4
-
5
- from __future__ import annotations
6
-
7
- import json
8
- import logging
9
- import sys
10
- from abc import ABC, abstractmethod
11
- from dataclasses import dataclass
12
- from enum import Enum
13
- from itertools import islice
14
- from pathlib import Path
15
- from typing import Any, Iterable, List, TypeVar
16
-
17
- from .vendor.mureq.mureq import HTTPException, Response, request
18
-
19
- CONTENT_TYPE_JSON = "application/json;charset=utf-8"
20
- CONTENT_TYPE_PLAIN = "text/plain;charset=utf-8"
21
- COUNT_METRIC_ITEMS_DICT = TypeVar("COUNT_METRIC_ITEMS_DICT", str, List[str])
22
- MAX_MINT_LINES_PER_REQUEST = 1000
23
- HTTP_BAD_REQUEST = 400
24
-
25
-
26
- class StatusValue(Enum):
27
- EMPTY = ""
28
- OK = "OK"
29
- GENERIC_ERROR = "GENERIC_ERROR"
30
- INVALID_ARGS_ERROR = "INVALID_ARGS_ERROR"
31
- EEC_CONNECTION_ERROR = "EEC_CONNECTION_ERROR"
32
- INVALID_CONFIG_ERROR = "INVALID_CONFIG_ERROR"
33
- AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
34
- DEVICE_CONNECTION_ERROR = "DEVICE_CONNECTION_ERROR"
35
- UNKNOWN_ERROR = "UNKNOWN_ERROR"
36
-
37
-
38
- class Status:
39
- def __init__(self, status: StatusValue = StatusValue.EMPTY, message: str = "", timestamp: int | None = None):
40
- self.status = status
41
- self.message = message
42
- self.timestamp = timestamp
43
-
44
- def to_json(self) -> dict:
45
- status = {"status": self.status.value, "message": self.message}
46
- if self.timestamp:
47
- status["timestamp"] = self.timestamp # type: ignore
48
- return status
49
-
50
- def __repr__(self):
51
- return json.dumps(self.to_json())
52
-
53
- def is_error(self) -> bool:
54
- return self.status not in (StatusValue.OK, StatusValue.EMPTY)
55
-
56
-
57
- class CommunicationClient(ABC):
58
- """
59
- Abstract class for extension communication
60
- """
61
-
62
- @abstractmethod
63
- def get_activation_config(self) -> dict:
64
- pass
65
-
66
- @abstractmethod
67
- def get_extension_config(self) -> str:
68
- pass
69
-
70
- @abstractmethod
71
- def get_feature_sets(self) -> dict[str, list[str]]:
72
- pass
73
-
74
- @abstractmethod
75
- def register_count_metrics(self, pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
76
- pass
77
-
78
- @abstractmethod
79
- def send_count_delta_signal(self, metric_keys: set[str]) -> None:
80
- pass
81
-
82
- @abstractmethod
83
- def send_status(self, status: Status) -> dict:
84
- pass
85
-
86
- @abstractmethod
87
- def send_keep_alive(self) -> str:
88
- pass
89
-
90
- @abstractmethod
91
- def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
92
- pass
93
-
94
- @abstractmethod
95
- def send_events(self, event: dict | list[dict], eec_enrichment: bool) -> dict | None:
96
- pass
97
-
98
- @abstractmethod
99
- def send_sfm_metrics(self, metrics: list[str]) -> MintResponse:
100
- pass
101
-
102
- @abstractmethod
103
- def get_cluster_time_diff(self) -> int:
104
- pass
105
-
106
- @abstractmethod
107
- def send_dt_event(self, event: dict) -> None:
108
- pass
109
-
110
-
111
- class HttpClient(CommunicationClient):
112
- """
113
- Concrete implementation of the client, this one handles the communication with the EEC
114
- """
115
-
116
- def __init__(self, base_url: str, datasource_id: str, id_token_file_path: str, logger: logging.Logger):
117
- # TODO - Do we need to replace 127.0.0.1 with localhost?
118
-
119
- self._activation_config_url = f"{base_url}/userconfig/{datasource_id}"
120
- self._extension_config_url = f"{base_url}/extconfig/{datasource_id}"
121
- self._metric_url = f"{base_url}/mint/{datasource_id}"
122
- self._sfm_url = f"{base_url}/sfm/{datasource_id}"
123
- self._keep_alive_url = f"{base_url}/alive/{datasource_id}"
124
- self._timediff_url = f"{base_url}/timediffms"
125
- self._events_url = f"{base_url}/logs/{datasource_id}"
126
- self._count_metric_register_url = f"{base_url}/countmetricregister/{datasource_id}"
127
- self._count_delta_signal_url = f"{base_url}/countmetricdeltasignal/{datasource_id}"
128
- self._feature_sets_query = "?feature_sets_json"
129
- self._event_ingest_url = f"{base_url}/events/{datasource_id}"
130
-
131
- with open(id_token_file_path) as f:
132
- id_token = f.read()
133
- self._headers = {"Authorization": f"Api-Token {id_token}"}
134
-
135
- self.logger = logger
136
-
137
- def _make_request(
138
- self,
139
- url: str,
140
- method: str = "GET",
141
- body: Any = None,
142
- extra_headers: dict | None = None,
143
- is_delta_signal: bool = False,
144
- ) -> Response:
145
- if extra_headers is None:
146
- extra_headers = {}
147
- headers = {**self._headers, **extra_headers}
148
-
149
- response = request(method, url, body=body, headers=headers)
150
- self.logger.debug(f"Response from {url}: {response}")
151
- if response.status_code >= HTTP_BAD_REQUEST:
152
- if not is_delta_signal:
153
- self.logger.warning(f"Error HTTP {response.status_code} from {url}: {response.content}")
154
- return response
155
-
156
- def get_activation_config(self) -> dict:
157
- try:
158
- response = self._make_request(self._activation_config_url, "GET")
159
- except HTTPException as err:
160
- self.logger.error(f"HTTP exception: {err}")
161
- return {}
162
-
163
- if response.status_code < HTTP_BAD_REQUEST:
164
- try:
165
- return response.json()
166
- except Exception as err:
167
- self.logger.error(f"JSON parse failure: {err}")
168
- return {}
169
- else:
170
- self.logger.error(f"Can't get activation configuration ({response.content}). Extension is stopped.")
171
- sys.exit(1)
172
-
173
- def get_extension_config(self) -> str:
174
- try:
175
- response = self._make_request(self._extension_config_url, "GET")
176
- return response.content.decode("utf-8")
177
- except HTTPException as err:
178
- self.logger.error(f"HTTP exception: {err}")
179
- return ""
180
-
181
- def get_feature_sets(self) -> dict[str, list[str]]:
182
- try:
183
- response = self._make_request(self._extension_config_url + self._feature_sets_query, "GET")
184
- except HTTPException as err:
185
- self.logger.error(f"HTTP exception: {err}")
186
- return {}
187
-
188
- if response.status_code < HTTP_BAD_REQUEST:
189
- try:
190
- return response.json()
191
- except Exception as err:
192
- self.logger.error(f"JSON parse failure: {err}")
193
- return {}
194
-
195
- return {}
196
-
197
- def register_count_metrics(self, json_pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
198
- register_data = json.dumps(json_pattern).encode("utf-8")
199
- try:
200
- response = self._make_request(
201
- self._count_metric_register_url,
202
- "POST",
203
- register_data,
204
- extra_headers={"Content-Type": CONTENT_TYPE_JSON},
205
- )
206
- if response.ok:
207
- self.logger.debug(
208
- f"Monotonic cache converter successful registration for metric {list(json_pattern.keys())}."
209
- )
210
- except HTTPException:
211
- self.logger.error(
212
- f"Monotonic cache converter registration request error for metric {list(json_pattern.keys())}."
213
- )
214
-
215
- def send_count_delta_signal(self, metric_keys: set[str]) -> None:
216
- json_data = {"metric_keys": list(metric_keys), "filter_dimensions": {}}
217
- delta_signal_data = json.dumps(json_data).encode("utf-8")
218
- try:
219
- response = self._make_request(
220
- self._count_delta_signal_url,
221
- "POST",
222
- delta_signal_data,
223
- extra_headers={"Content-Type": CONTENT_TYPE_JSON},
224
- is_delta_signal=True,
225
- )
226
- if response.ok:
227
- self.logger.debug(
228
- f"Monotonic converter cache delta calculation signal success for metric {metric_keys}."
229
- )
230
- else:
231
- self.logger.debug(
232
- f"Not enough metrics of type {metric_keys} cached in monotonic cache converter to calculate delta."
233
- )
234
- except HTTPException:
235
- self.logger.error(
236
- f"Monotonic cache converter delta calculation signal request error for metric {metric_keys}."
237
- )
238
-
239
- def send_dt_event(self, event: dict[str, str | int | dict[str, str]]):
240
- json_data = json.dumps(event).encode("utf-8")
241
- try:
242
- response = self._make_request(
243
- self._event_ingest_url, "POST", json_data, extra_headers={"Content-Type": CONTENT_TYPE_JSON}
244
- )
245
- if response.ok:
246
- self.logger.debug(f"DT Event sent to EEC, content: {json_data.decode('utf-8')}")
247
- else:
248
- self.logger.debug(f"DT Event request failed: {response.content}")
249
- except HTTPException:
250
- self.logger.error(f"DT Event request HTTP exception, request body: {json_data.decode('utf-8')}")
251
-
252
- def send_status(self, status: Status) -> dict:
253
- encoded_data = json.dumps(status.to_json()).encode("utf-8")
254
- self.logger.debug(f"Sending status to EEC: {status}")
255
- response = self._make_request(
256
- self._keep_alive_url, "POST", encoded_data, extra_headers={"Content-Type": CONTENT_TYPE_JSON}
257
- ).content
258
- return json.loads(response.decode("utf-8"))
259
-
260
- def send_keep_alive(self):
261
- return self.send_status(Status())
262
-
263
- def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
264
- total_lines = len(mint_lines)
265
- lines_sent = 0
266
-
267
- self.logger.debug(f"Start sending {total_lines} metrics to the EEC")
268
- responses = []
269
-
270
- # We divide into chunks of MAX_MINT_LINES_PER_REQUEST lines to avoid hitting the body size limit
271
- chunks = divide_into_chunks(mint_lines, MAX_MINT_LINES_PER_REQUEST)
272
-
273
- for chunk in chunks:
274
- lines_in_chunk = len(chunk)
275
- lines_sent += lines_in_chunk
276
- self.logger.debug(f"Sending chunk with {lines_in_chunk} metric lines. ({lines_sent}/{total_lines})")
277
- mint_data = "\n".join(chunk).encode("utf-8")
278
- response = self._make_request(
279
- self._metric_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
280
- ).json()
281
- self.logger.debug(f"{self._metric_url}: {response}")
282
- mint_response = MintResponse.from_json(response)
283
- responses.append(mint_response)
284
- return responses
285
-
286
- def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
287
- self.logger.debug(f"Sending log events: {events}")
288
- event_data = json.dumps(events).encode("utf-8")
289
- try:
290
- # EEC returns empty body on success
291
- return self._make_request(
292
- self._events_url,
293
- "POST",
294
- event_data,
295
- extra_headers={"Content-Type": CONTENT_TYPE_JSON, "eec-enrichment": str(eec_enrichment).lower()},
296
- ).json()
297
- except json.JSONDecodeError:
298
- return None
299
-
300
- def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
301
- mint_data = "\n".join(mint_lines).encode("utf-8")
302
- return MintResponse.from_json(
303
- self._make_request(
304
- self._sfm_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
305
- ).json()
306
- )
307
-
308
- def get_cluster_time_diff(self) -> int:
309
- response = self._make_request(self._timediff_url, "GET")
310
- time_diff = response.json()["clusterDiffMs"]
311
- return time_diff
312
-
313
-
314
- class DebugClient(CommunicationClient):
315
- """
316
- This client is used for debugging purposes
317
- It does not send metrics to Dynatrace, but prints them to the console
318
- """
319
-
320
- def __init__(
321
- self,
322
- activation_config_path: str,
323
- extension_config_path: str,
324
- logger: logging.Logger,
325
- local_ingest: bool = False,
326
- local_ingest_port: int = 14499,
327
- ):
328
- self.activation_config = {}
329
- if activation_config_path and Path(activation_config_path).exists():
330
- with open(activation_config_path) as f:
331
- self.activation_config = json.load(f)
332
-
333
- self.extension_config = ""
334
- if not extension_config_path:
335
- extension_config_path = "extension/extension.yaml"
336
- if Path(extension_config_path).exists():
337
- with open(extension_config_path) as f:
338
- self.extension_config = f.read()
339
- self.logger = logger
340
- self.local_ingest = local_ingest
341
- self.local_ingest_port = local_ingest_port
342
-
343
- def get_activation_config(self) -> dict:
344
- return self.activation_config
345
-
346
- def get_extension_config(self) -> str:
347
- return self.extension_config
348
-
349
- def get_feature_sets(self) -> dict[str, list[str]]:
350
- # This is only called from dt-sdk run, where PyYaml is installed because of dt-cli
351
- # Do NOT move this to the top of the file
352
- import yaml # type: ignore
353
-
354
- # Grab the feature sets from the extension.yaml file
355
- extension_yaml = yaml.safe_load(self.extension_config)
356
- if not extension_yaml:
357
- return {}
358
-
359
- yaml_feature_sets = extension_yaml.get("python", {}).get("featureSets", [])
360
- if not yaml_feature_sets:
361
- return {}
362
-
363
- # Construct the object that the SDK expects
364
- feature_sets = {}
365
- for feature_set in yaml_feature_sets:
366
- feature_set_name = feature_set["featureSet"]
367
- if feature_set_name in self.activation_config.get("featureSets", []):
368
- feature_sets[feature_set_name] = [metric["key"] for metric in feature_set["metrics"]]
369
-
370
- return feature_sets
371
-
372
- def register_count_metrics(self, pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
373
- self.logger.info(f"Registering metrics in converter: {pattern}")
374
-
375
- def send_count_delta_signal(self, metric_keys: set[str]) -> None:
376
- self.logger.info(f"Sending delta signal for: {metric_keys}")
377
-
378
- def send_dt_event(self, event: dict) -> None:
379
- self.logger.info(f"Sending DT Event: {event}")
380
-
381
- def send_status(self, status: Status) -> dict:
382
- self.logger.info(f"send_status: '{status}'")
383
- return {}
384
-
385
- def send_keep_alive(self):
386
- return self.send_status(Status())
387
-
388
- def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
389
- responses = []
390
- for line in mint_lines:
391
- self.logger.info(f"send_metric: {line}")
392
-
393
- if self.local_ingest:
394
- mint_data = "\n".join(mint_lines).encode("utf-8")
395
- response = request(
396
- "POST",
397
- f"http://localhost:{self.local_ingest_port}/metrics/ingest",
398
- body=mint_data,
399
- headers={"Content-Type": CONTENT_TYPE_PLAIN},
400
- ).json()
401
- mint_response = MintResponse.from_json(response)
402
- responses.append(mint_response)
403
-
404
- if not responses:
405
- responses = [MintResponse(lines_invalid=0, lines_ok=len(mint_lines), error=None, warnings=None)]
406
- return responses
407
-
408
- def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
409
- self.logger.info(f"send_events (enrichment = {eec_enrichment}): {events}")
410
- return None
411
-
412
- def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
413
- for line in mint_lines:
414
- self.logger.info(f"send_sfm_metric: {line}")
415
- return MintResponse(lines_invalid=0, lines_ok=len(mint_lines), error=None, warnings=None)
416
-
417
- def get_cluster_time_diff(self) -> int:
418
- return 0
419
-
420
-
421
- def divide_into_chunks(iterable: Iterable, chunk_size: int) -> Iterable:
422
- """
423
- Yield successive n-sized chunks from iterable.
424
- Example: _chunk([1, 2, 3, 4, 5, 6, 7, 8, 9], 3) -> [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
425
-
426
- :param iterable: The iterable to chunk
427
- :param chunk_size: The size of the chunks
428
- """
429
- iterator = iter(iterable)
430
- while True:
431
- subset = list(islice(iterator, chunk_size))
432
- if not subset:
433
- return
434
- yield subset
435
-
436
-
437
- @dataclass
438
- class MintResponse:
439
- lines_ok: int
440
- lines_invalid: int
441
- error: dict | None
442
- warnings: dict | None
443
-
444
- @staticmethod
445
- def from_json(json_data: dict) -> MintResponse:
446
- return MintResponse(
447
- lines_ok=json_data.get("linesOk", 0),
448
- lines_invalid=json_data.get("linesInvalid", 0),
449
- error=json_data.get("error"),
450
- warnings=json_data.get("warnings"),
451
- )
452
-
453
- def __str__(self) -> str:
454
- return f"MintResponse(lines_ok={self.lines_ok}, lines_invalid={self.lines_invalid}, error={self.error}, warnings={self.warnings})"
1
+ # SPDX-FileCopyrightText: 2023-present Dynatrace LLC
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import random
10
+ import sys
11
+ import time
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from itertools import islice
16
+ from pathlib import Path
17
+ from typing import Any, Iterable, List, TypeVar
18
+
19
+ from .vendor.mureq.mureq import HTTPException, Response, request
20
+
21
+ CONTENT_TYPE_JSON = "application/json;charset=utf-8"
22
+ CONTENT_TYPE_PLAIN = "text/plain;charset=utf-8"
23
+ COUNT_METRIC_ITEMS_DICT = TypeVar("COUNT_METRIC_ITEMS_DICT", str, List[str])
24
+ MAX_MINT_LINES_PER_REQUEST = 1000
25
+ HTTP_BAD_REQUEST = 400
26
+
27
+
28
+ class StatusValue(Enum):
29
+ EMPTY = ""
30
+ OK = "OK"
31
+ GENERIC_ERROR = "GENERIC_ERROR"
32
+ INVALID_ARGS_ERROR = "INVALID_ARGS_ERROR"
33
+ EEC_CONNECTION_ERROR = "EEC_CONNECTION_ERROR"
34
+ INVALID_CONFIG_ERROR = "INVALID_CONFIG_ERROR"
35
+ AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
36
+ DEVICE_CONNECTION_ERROR = "DEVICE_CONNECTION_ERROR"
37
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
38
+
39
+
40
+ class Status:
41
+ def __init__(self, status: StatusValue = StatusValue.EMPTY, message: str = "", timestamp: int | None = None):
42
+ self.status = status
43
+ self.message = message
44
+ self.timestamp = timestamp
45
+
46
+ def to_json(self) -> dict:
47
+ status = {"status": self.status.value, "message": self.message}
48
+ if self.timestamp:
49
+ status["timestamp"] = self.timestamp # type: ignore
50
+ return status
51
+
52
+ def __repr__(self):
53
+ return json.dumps(self.to_json())
54
+
55
+ def is_error(self) -> bool:
56
+ return self.status not in (StatusValue.OK, StatusValue.EMPTY)
57
+
58
+
59
+ class CommunicationClient(ABC):
60
+ """
61
+ Abstract class for extension communication
62
+ """
63
+
64
+ @abstractmethod
65
+ def get_activation_config(self) -> dict:
66
+ pass
67
+
68
+ @abstractmethod
69
+ def get_extension_config(self) -> str:
70
+ pass
71
+
72
+ @abstractmethod
73
+ def get_feature_sets(self) -> dict[str, list[str]]:
74
+ pass
75
+
76
+ @abstractmethod
77
+ def register_count_metrics(self, pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
78
+ pass
79
+
80
+ @abstractmethod
81
+ def send_count_delta_signal(self, metric_keys: set[str]) -> None:
82
+ pass
83
+
84
+ @abstractmethod
85
+ def send_status(self, status: Status) -> dict:
86
+ pass
87
+
88
+ @abstractmethod
89
+ def send_keep_alive(self) -> str:
90
+ pass
91
+
92
+ @abstractmethod
93
+ def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
94
+ pass
95
+
96
+ @abstractmethod
97
+ def send_events(self, event: dict | list[dict], eec_enrichment: bool) -> dict | None:
98
+ pass
99
+
100
+ @abstractmethod
101
+ def send_sfm_metrics(self, metrics: list[str]) -> MintResponse:
102
+ pass
103
+
104
+ @abstractmethod
105
+ def get_cluster_time_diff(self) -> int:
106
+ pass
107
+
108
+ @abstractmethod
109
+ def send_dt_event(self, event: dict) -> None:
110
+ pass
111
+
112
+
113
+ class HttpClient(CommunicationClient):
114
+ """
115
+ Concrete implementation of the client, this one handles the communication with the EEC
116
+ """
117
+
118
+ def __init__(self, base_url: str, datasource_id: str, id_token_file_path: str, logger: logging.Logger):
119
+ self._activation_config_url = f"{base_url}/userconfig/{datasource_id}"
120
+ self._extension_config_url = f"{base_url}/extconfig/{datasource_id}"
121
+ self._metric_url = f"{base_url}/mint/{datasource_id}"
122
+ self._sfm_url = f"{base_url}/sfm/{datasource_id}"
123
+ self._keep_alive_url = f"{base_url}/alive/{datasource_id}"
124
+ self._timediff_url = f"{base_url}/timediffms"
125
+ self._events_url = f"{base_url}/logs/{datasource_id}"
126
+ self._count_metric_register_url = f"{base_url}/countmetricregister/{datasource_id}"
127
+ self._count_delta_signal_url = f"{base_url}/countmetricdeltasignal/{datasource_id}"
128
+ self._feature_sets_query = "?feature_sets_json"
129
+ self._event_ingest_url = f"{base_url}/events/{datasource_id}"
130
+
131
+ with open(id_token_file_path) as f:
132
+ id_token = f.read()
133
+ self._headers = {"Authorization": f"Api-Token {id_token}"}
134
+
135
+ self.logger = logger
136
+
137
+ def _make_request(
138
+ self,
139
+ url: str,
140
+ method: str = "GET",
141
+ body: Any = None,
142
+ extra_headers: dict | None = None,
143
+ is_delta_signal: bool = False,
144
+ ) -> Response:
145
+ if extra_headers is None:
146
+ extra_headers = {}
147
+ headers = {**self._headers, **extra_headers}
148
+
149
+ response = request(method, url, body=body, headers=headers)
150
+ self.logger.debug(f"Response from {url}: {response}")
151
+ if response.status_code >= HTTP_BAD_REQUEST:
152
+ if not is_delta_signal:
153
+ self.logger.warning(f"Error HTTP {response.status_code} from {url}: {response.content}")
154
+ return response
155
+
156
+ def get_activation_config(self) -> dict:
157
+ try:
158
+ response = self._make_request(self._activation_config_url, "GET")
159
+ except HTTPException as err:
160
+ self.logger.error(f"HTTP exception: {err}")
161
+ return {}
162
+
163
+ if response.status_code < HTTP_BAD_REQUEST:
164
+ try:
165
+ return response.json()
166
+ except Exception as err:
167
+ self.logger.error(f"JSON parse failure: {err}")
168
+ return {}
169
+ else:
170
+ self.logger.error(f"Can't get activation configuration ({response.content}). Extension is stopped.")
171
+ sys.exit(1)
172
+
173
+ def get_extension_config(self) -> str:
174
+ try:
175
+ response = self._make_request(self._extension_config_url, "GET")
176
+ return response.content.decode("utf-8")
177
+ except HTTPException as err:
178
+ self.logger.error(f"HTTP exception: {err}")
179
+ return ""
180
+
181
+ def get_feature_sets(self) -> dict[str, list[str]]:
182
+ try:
183
+ response = self._make_request(self._extension_config_url + self._feature_sets_query, "GET")
184
+ except HTTPException as err:
185
+ self.logger.error(f"HTTP exception: {err}")
186
+ return {}
187
+
188
+ if response.status_code < HTTP_BAD_REQUEST:
189
+ try:
190
+ return response.json()
191
+ except Exception as err:
192
+ self.logger.error(f"JSON parse failure: {err}")
193
+ return {}
194
+
195
+ return {}
196
+
197
+ def register_count_metrics(self, json_pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
198
+ register_data = json.dumps(json_pattern).encode("utf-8")
199
+ try:
200
+ response = self._make_request(
201
+ self._count_metric_register_url,
202
+ "POST",
203
+ register_data,
204
+ extra_headers={"Content-Type": CONTENT_TYPE_JSON},
205
+ )
206
+ if response.ok:
207
+ self.logger.debug(
208
+ f"Monotonic cache converter successful registration for metric {list(json_pattern.keys())}."
209
+ )
210
+ except HTTPException:
211
+ self.logger.error(
212
+ f"Monotonic cache converter registration request error for metric {list(json_pattern.keys())}."
213
+ )
214
+
215
+ def send_count_delta_signal(self, metric_keys: set[str]) -> None:
216
+ json_data = {"metric_keys": list(metric_keys), "filter_dimensions": {}}
217
+ delta_signal_data = json.dumps(json_data).encode("utf-8")
218
+ try:
219
+ response = self._make_request(
220
+ self._count_delta_signal_url,
221
+ "POST",
222
+ delta_signal_data,
223
+ extra_headers={"Content-Type": CONTENT_TYPE_JSON},
224
+ is_delta_signal=True,
225
+ )
226
+ if response.ok:
227
+ self.logger.debug(
228
+ f"Monotonic converter cache delta calculation signal success for metric {metric_keys}."
229
+ )
230
+ else:
231
+ self.logger.debug(
232
+ f"Not enough metrics of type {metric_keys} cached in monotonic cache converter to calculate delta."
233
+ )
234
+ except HTTPException:
235
+ self.logger.error(
236
+ f"Monotonic cache converter delta calculation signal request error for metric {metric_keys}."
237
+ )
238
+
239
+ def send_dt_event(self, event: dict[str, str | int | dict[str, str]]):
240
+ json_data = json.dumps(event).encode("utf-8")
241
+ try:
242
+ response = self._make_request(
243
+ self._event_ingest_url, "POST", json_data, extra_headers={"Content-Type": CONTENT_TYPE_JSON}
244
+ )
245
+ if response.ok:
246
+ self.logger.debug(f"DT Event sent to EEC, content: {json_data.decode('utf-8')}")
247
+ else:
248
+ self.logger.debug(f"DT Event request failed: {response.content}")
249
+ except HTTPException:
250
+ self.logger.error(f"DT Event request HTTP exception, request body: {json_data.decode('utf-8')}")
251
+
252
+ def send_status(self, status: Status) -> dict:
253
+ encoded_data = json.dumps(status.to_json()).encode("utf-8")
254
+ self.logger.debug(f"Sending status to EEC: {status}")
255
+ response = self._make_request(
256
+ self._keep_alive_url, "POST", encoded_data, extra_headers={"Content-Type": CONTENT_TYPE_JSON}
257
+ ).content
258
+ return json.loads(response.decode("utf-8"))
259
+
260
+ def send_keep_alive(self):
261
+ return self.send_status(Status())
262
+
263
+ def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
264
+ total_lines = len(mint_lines)
265
+ lines_sent = 0
266
+
267
+ self.logger.debug(f"Start sending {total_lines} metrics to the EEC")
268
+ responses = []
269
+
270
+ # We divide into chunks of MAX_MINT_LINES_PER_REQUEST lines to avoid hitting the body size limit
271
+ chunks = divide_into_chunks(mint_lines, MAX_MINT_LINES_PER_REQUEST)
272
+
273
+ for chunk in chunks:
274
+ lines_in_chunk = len(chunk)
275
+ lines_sent += lines_in_chunk
276
+ self.logger.debug(f"Sending chunk with {lines_in_chunk} metric lines. ({lines_sent}/{total_lines})")
277
+ mint_data = "\n".join(chunk).encode("utf-8")
278
+ response = self._make_request(
279
+ self._metric_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
280
+ ).json()
281
+ self.logger.debug(f"{self._metric_url}: {response}")
282
+ mint_response = MintResponse.from_json(response)
283
+ responses.append(mint_response)
284
+ return responses
285
+
286
+ def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
287
+ self.logger.debug(f"Sending log events: {events}")
288
+ event_data = json.dumps(events).encode("utf-8")
289
+ try:
290
+ # EEC returns empty body on success
291
+ return self._make_request(
292
+ self._events_url,
293
+ "POST",
294
+ event_data,
295
+ extra_headers={"Content-Type": CONTENT_TYPE_JSON, "eec-enrichment": str(eec_enrichment).lower()},
296
+ ).json()
297
+ except json.JSONDecodeError:
298
+ return None
299
+
300
+ def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
301
+ mint_data = "\n".join(mint_lines).encode("utf-8")
302
+ return MintResponse.from_json(
303
+ self._make_request(
304
+ self._sfm_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
305
+ ).json()
306
+ )
307
+
308
+ def get_cluster_time_diff(self) -> int:
309
+ response = self._make_request(self._timediff_url, "GET")
310
+ time_diff = response.json()["clusterDiffMs"]
311
+ return time_diff
312
+
313
+
314
+ class DebugClient(CommunicationClient):
315
+ """
316
+ This client is used for debugging purposes
317
+ It does not send metrics to Dynatrace, but prints them to the console
318
+ """
319
+
320
+ def __init__(
321
+ self,
322
+ activation_config_path: str,
323
+ extension_config_path: str,
324
+ logger: logging.Logger,
325
+ local_ingest: bool = False,
326
+ local_ingest_port: int = 14499,
327
+ print_metrics: bool = True
328
+ ):
329
+ self.activation_config = {}
330
+ if activation_config_path and Path(activation_config_path).exists():
331
+ with open(activation_config_path) as f:
332
+ self.activation_config = json.load(f)
333
+
334
+ self.extension_config = ""
335
+ if not extension_config_path:
336
+ extension_config_path = "extension/extension.yaml"
337
+ if Path(extension_config_path).exists():
338
+ with open(extension_config_path) as f:
339
+ self.extension_config = f.read()
340
+ self.logger = logger
341
+ self.local_ingest = local_ingest
342
+ self.local_ingest_port = local_ingest_port
343
+ self.print_metrics = print_metrics
344
+
345
+ def get_activation_config(self) -> dict:
346
+ return self.activation_config
347
+
348
+ def get_extension_config(self) -> str:
349
+ return self.extension_config
350
+
351
+ def get_feature_sets(self) -> dict[str, list[str]]:
352
+ # This is only called from dt-sdk run, where PyYaml is installed because of dt-cli
353
+ # Do NOT move this to the top of the file
354
+ import yaml # type: ignore
355
+
356
+ # Grab the feature sets from the extension.yaml file
357
+ extension_yaml = yaml.safe_load(self.extension_config)
358
+ if not extension_yaml:
359
+ return {}
360
+
361
+ yaml_feature_sets = extension_yaml.get("python", {}).get("featureSets", [])
362
+ if not yaml_feature_sets:
363
+ return {}
364
+
365
+ # Construct the object that the SDK expects
366
+ feature_sets = {}
367
+ for feature_set in yaml_feature_sets:
368
+ feature_set_name = feature_set["featureSet"]
369
+ if feature_set_name in self.activation_config.get("featureSets", []):
370
+ feature_sets[feature_set_name] = [metric["key"] for metric in feature_set["metrics"]]
371
+
372
+ return feature_sets
373
+
374
+ def register_count_metrics(self, pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
375
+ self.logger.info(f"Registering metrics in converter: {pattern}")
376
+
377
+ def send_count_delta_signal(self, metric_keys: set[str]) -> None:
378
+ self.logger.info(f"Sending delta signal for: {metric_keys}")
379
+
380
+ def send_dt_event(self, event: dict) -> None:
381
+ self.logger.info(f"Sending DT Event: {event}")
382
+
383
+ def send_status(self, status: Status) -> dict:
384
+ self.logger.info(f"send_status: '{status}'")
385
+ return {}
386
+
387
+ def send_keep_alive(self):
388
+ return self.send_status(Status())
389
+
390
+ def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
391
+ total_lines = len(mint_lines)
392
+ lines_sent = 0
393
+
394
+ self.logger.info(f"Start sending {total_lines} metrics to the EEC")
395
+
396
+ responses = []
397
+
398
+ chunks = divide_into_chunks(mint_lines, MAX_MINT_LINES_PER_REQUEST)
399
+ for chunk in chunks:
400
+ lines_in_chunk = len(chunk)
401
+ lines_sent += lines_in_chunk
402
+ self.logger.debug(f"Sending chunk with {lines_in_chunk} metric lines. ({lines_sent}/{total_lines})")
403
+
404
+ if self.local_ingest:
405
+ mint_data = "\n".join(chunk).encode("utf-8")
406
+ response = request(
407
+ "POST",
408
+ f"http://localhost:{self.local_ingest_port}/metrics/ingest",
409
+ body=mint_data,
410
+ headers={"Content-Type": CONTENT_TYPE_PLAIN},
411
+ ).json()
412
+ mint_response = MintResponse.from_json(response)
413
+ responses.append(mint_response)
414
+ else:
415
+ if self.print_metrics:
416
+ for line in mint_lines:
417
+ self.logger.info(f"send_metric: {line}")
418
+
419
+ response = MintResponse(lines_invalid=0, lines_ok=len(chunk), error=None, warnings=None)
420
+ responses.append(response)
421
+ return responses
422
+
423
+ def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
424
+ self.logger.info(f"send_events (enrichment = {eec_enrichment}): {events}")
425
+ return None
426
+
427
+ def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
428
+ for line in mint_lines:
429
+ self.logger.info(f"send_sfm_metric: {line}")
430
+ return MintResponse(lines_invalid=0, lines_ok=len(mint_lines), error=None, warnings=None)
431
+
432
+ def get_cluster_time_diff(self) -> int:
433
+ return 0
434
+
435
+
436
+ def divide_into_chunks(iterable: Iterable, chunk_size: int) -> Iterable:
437
+ """
438
+ Yield successive n-sized chunks from iterable.
439
+ Example: _chunk([1, 2, 3, 4, 5, 6, 7, 8, 9], 3) -> [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
440
+
441
+ :param iterable: The iterable to chunk
442
+ :param chunk_size: The size of the chunks
443
+ """
444
+ iterator = iter(iterable)
445
+ while True:
446
+ subset = list(islice(iterator, chunk_size))
447
+ if not subset:
448
+ return
449
+ yield subset
450
+
451
+
452
+ @dataclass
453
+ class MintResponse:
454
+ lines_ok: int
455
+ lines_invalid: int
456
+ error: dict | None
457
+ warnings: dict | None
458
+
459
+ @staticmethod
460
+ def from_json(json_data: dict) -> MintResponse:
461
+ return MintResponse(
462
+ lines_ok=json_data.get("linesOk", 0),
463
+ lines_invalid=json_data.get("linesInvalid", 0),
464
+ error=json_data.get("error"),
465
+ warnings=json_data.get("warnings"),
466
+ )
467
+
468
+ def __str__(self) -> str:
469
+ return f"MintResponse(lines_ok={self.lines_ok}, lines_invalid={self.lines_invalid}, error={self.error}, warnings={self.warnings})"