dt-extensions-sdk 1.1.9__py3-none-any.whl → 1.1.10__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.9.dist-info → dt_extensions_sdk-1.1.10.dist-info}/METADATA +2 -2
  2. dt_extensions_sdk-1.1.10.dist-info/RECORD +33 -0
  3. {dt_extensions_sdk-1.1.9.dist-info → dt_extensions_sdk-1.1.10.dist-info}/WHEEL +1 -1
  4. {dt_extensions_sdk-1.1.9.dist-info → dt_extensions_sdk-1.1.10.dist-info}/licenses/LICENSE.txt +9 -9
  5. dynatrace_extension/__about__.py +4 -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 +414 -422
  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 +454 -446
  23. dynatrace_extension/sdk/event.py +19 -19
  24. dynatrace_extension/sdk/extension.py +1034 -1033
  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.9.dist-info/RECORD +0 -33
  31. {dt_extensions_sdk-1.1.9.dist-info → dt_extensions_sdk-1.1.10.dist-info}/entry_points.txt +0 -0
@@ -1,446 +1,454 @@
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
- responses = []
265
-
266
- # We divide into chunks of MAX_MINT_LINES_PER_REQUEST lines to avoid hitting the body size limit
267
- chunks = divide_into_chunks(mint_lines, MAX_MINT_LINES_PER_REQUEST)
268
-
269
- for chunk in chunks:
270
- mint_data = "\n".join(chunk).encode("utf-8")
271
- response = self._make_request(
272
- self._metric_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
273
- ).json()
274
- mint_response = MintResponse.from_json(response)
275
- responses.append(mint_response)
276
- return responses
277
-
278
- def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
279
- self.logger.debug(f"Sending log events: {events}")
280
- event_data = json.dumps(events).encode("utf-8")
281
- try:
282
- # EEC returns empty body on success
283
- return self._make_request(
284
- self._events_url,
285
- "POST",
286
- event_data,
287
- extra_headers={"Content-Type": CONTENT_TYPE_JSON, "eec-enrichment": str(eec_enrichment).lower()},
288
- ).json()
289
- except json.JSONDecodeError:
290
- return None
291
-
292
- def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
293
- mint_data = "\n".join(mint_lines).encode("utf-8")
294
- return MintResponse.from_json(
295
- self._make_request(
296
- self._sfm_url, "POST", mint_data, extra_headers={"Content-Type": CONTENT_TYPE_PLAIN}
297
- ).json()
298
- )
299
-
300
- def get_cluster_time_diff(self) -> int:
301
- response = self._make_request(self._timediff_url, "GET")
302
- time_diff = response.json()["clusterDiffMs"]
303
- return time_diff
304
-
305
-
306
- class DebugClient(CommunicationClient):
307
- """
308
- This client is used for debugging purposes
309
- It does not send metrics to Dynatrace, but prints them to the console
310
- """
311
-
312
- def __init__(
313
- self,
314
- activation_config_path: str,
315
- extension_config_path: str,
316
- logger: logging.Logger,
317
- local_ingest: bool = False,
318
- local_ingest_port: int = 14499,
319
- ):
320
- self.activation_config = {}
321
- if activation_config_path and Path(activation_config_path).exists():
322
- with open(activation_config_path) as f:
323
- self.activation_config = json.load(f)
324
-
325
- self.extension_config = ""
326
- if not extension_config_path:
327
- extension_config_path = "extension/extension.yaml"
328
- if Path(extension_config_path).exists():
329
- with open(extension_config_path) as f:
330
- self.extension_config = f.read()
331
- self.logger = logger
332
- self.local_ingest = local_ingest
333
- self.local_ingest_port = local_ingest_port
334
-
335
- def get_activation_config(self) -> dict:
336
- return self.activation_config
337
-
338
- def get_extension_config(self) -> str:
339
- return self.extension_config
340
-
341
- def get_feature_sets(self) -> dict[str, list[str]]:
342
- # This is only called from dt-sdk run, where PyYaml is installed because of dt-cli
343
- # Do NOT move this to the top of the file
344
- import yaml # type: ignore
345
-
346
- # Grab the feature sets from the extension.yaml file
347
- extension_yaml = yaml.safe_load(self.extension_config)
348
- if not extension_yaml:
349
- return {}
350
-
351
- yaml_feature_sets = extension_yaml.get("python", {}).get("featureSets", [])
352
- if not yaml_feature_sets:
353
- return {}
354
-
355
- # Construct the object that the SDK expects
356
- feature_sets = {}
357
- for feature_set in yaml_feature_sets:
358
- feature_set_name = feature_set["featureSet"]
359
- if feature_set_name in self.activation_config.get("featureSets", []):
360
- feature_sets[feature_set_name] = [metric["key"] for metric in feature_set["metrics"]]
361
-
362
- return feature_sets
363
-
364
- def register_count_metrics(self, pattern: dict[str, dict[str, COUNT_METRIC_ITEMS_DICT]]) -> None:
365
- self.logger.info(f"Registering metrics in converter: {pattern}")
366
-
367
- def send_count_delta_signal(self, metric_keys: set[str]) -> None:
368
- self.logger.info(f"Sending delta signal for: {metric_keys}")
369
-
370
- def send_dt_event(self, event: dict) -> None:
371
- self.logger.info(f"Sending DT Event: {event}")
372
-
373
- def send_status(self, status: Status) -> dict:
374
- self.logger.info(f"send_status: '{status}'")
375
- return {}
376
-
377
- def send_keep_alive(self):
378
- return self.send_status(Status())
379
-
380
- def send_metrics(self, mint_lines: list[str]) -> list[MintResponse]:
381
- responses = []
382
- for line in mint_lines:
383
- self.logger.info(f"send_metric: {line}")
384
-
385
- if self.local_ingest:
386
- mint_data = "\n".join(mint_lines).encode("utf-8")
387
- response = request(
388
- "POST",
389
- f"http://localhost:{self.local_ingest_port}/metrics/ingest",
390
- body=mint_data,
391
- headers={"Content-Type": CONTENT_TYPE_PLAIN},
392
- ).json()
393
- mint_response = MintResponse.from_json(response)
394
- responses.append(mint_response)
395
-
396
- if not responses:
397
- responses = [MintResponse(lines_invalid=0, lines_ok=len(mint_lines), error=None, warnings=None)]
398
- return responses
399
-
400
- def send_events(self, events: dict | list[dict], eec_enrichment: bool = True) -> dict | None:
401
- self.logger.info(f"send_events (enrichment = {eec_enrichment}): {events}")
402
- return None
403
-
404
- def send_sfm_metrics(self, mint_lines: list[str]) -> MintResponse:
405
- for line in mint_lines:
406
- self.logger.info(f"send_sfm_metric: {line}")
407
- return MintResponse(lines_invalid=0, lines_ok=len(mint_lines), error=None, warnings=None)
408
-
409
- def get_cluster_time_diff(self) -> int:
410
- return 0
411
-
412
-
413
- def divide_into_chunks(iterable: Iterable, chunk_size: int) -> Iterable:
414
- """
415
- Yield successive n-sized chunks from iterable.
416
- Example: _chunk([1, 2, 3, 4, 5, 6, 7, 8, 9], 3) -> [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
417
-
418
- :param iterable: The iterable to chunk
419
- :param chunk_size: The size of the chunks
420
- """
421
- iterator = iter(iterable)
422
- while True:
423
- subset = list(islice(iterator, chunk_size))
424
- if not subset:
425
- return
426
- yield subset
427
-
428
-
429
- @dataclass
430
- class MintResponse:
431
- lines_ok: int
432
- lines_invalid: int
433
- error: dict | None
434
- warnings: dict | None
435
-
436
- @staticmethod
437
- def from_json(json_data: dict) -> MintResponse:
438
- return MintResponse(
439
- lines_ok=json_data.get("linesOk", 0),
440
- lines_invalid=json_data.get("linesInvalid", 0),
441
- error=json_data.get("error"),
442
- warnings=json_data.get("warnings"),
443
- )
444
-
445
- def __str__(self) -> str:
446
- 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 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})"