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,1034 +1,1037 @@
1
- # SPDX-FileCopyrightText: 2023-present Dynatrace LLC
2
- #
3
- # SPDX-License-Identifier: MIT
4
-
5
- import logging
6
- import sched
7
- import signal
8
- import sys
9
- import threading
10
- import time
11
- from argparse import ArgumentParser
12
- from concurrent.futures import ThreadPoolExecutor
13
- from datetime import datetime, timedelta, timezone
14
- from enum import Enum
15
- from itertools import chain
16
- from threading import Lock, RLock, active_count
17
- from typing import Any, Callable, ClassVar, Dict, List, NamedTuple, Optional, Union
18
-
19
- from ..__about__ import __version__
20
- from .activation import ActivationConfig, ActivationType
21
- from .callback import WrappedCallback
22
- from .communication import CommunicationClient, DebugClient, HttpClient, Status, StatusValue
23
- from .event import Severity
24
- from .metric import Metric, MetricType, SfmMetric, SummaryStat
25
- from .runtime import RuntimeProperties
26
-
27
- HEARTBEAT_INTERVAL = timedelta(seconds=30)
28
- METRIC_SENDING_INTERVAL = timedelta(seconds=30)
29
- SFM_METRIC_SENDING_INTERVAL = timedelta(seconds=60)
30
- TIME_DIFF_INTERVAL = timedelta(seconds=60)
31
-
32
- CALLBACKS_THREAD_POOL_SIZE = 100
33
- INTERNAL_THREAD_POOL_SIZE = 20
34
-
35
- RFC_3339_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
36
- DATASOURCE_TYPE = "python"
37
-
38
- logging.raiseExceptions = False
39
- formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s (%(threadName)s): %(message)s")
40
- error_handler = logging.StreamHandler()
41
- error_handler.addFilter(lambda record: record.levelno >= logging.ERROR)
42
- error_handler.setFormatter(formatter)
43
- std_handler = logging.StreamHandler(sys.stdout)
44
- std_handler.addFilter(lambda record: record.levelno < logging.ERROR)
45
- std_handler.setFormatter(formatter)
46
- extension_logger = logging.getLogger(__name__)
47
- extension_logger.setLevel(logging.INFO)
48
- extension_logger.addHandler(error_handler)
49
- extension_logger.addHandler(std_handler)
50
-
51
- api_logger = logging.getLogger("api")
52
- api_logger.setLevel(logging.INFO)
53
- api_logger.addHandler(error_handler)
54
- api_logger.addHandler(std_handler)
55
-
56
- DT_EVENT_SCHEMA = {
57
- "eventType": str,
58
- "title": str,
59
- "startTime": int,
60
- "endTime": int,
61
- "timeout": int,
62
- "entitySelector": str,
63
- "properties": dict,
64
- }
65
-
66
-
67
- class AggregationMode(Enum):
68
- ALL = "include_all"
69
- NONE = "include_none"
70
- LIST = "include_list"
71
-
72
-
73
- class DtEventType(str, Enum):
74
- """Event type.
75
-
76
- Note:
77
- Official API v2 documentation:
78
-
79
- https://docs.dynatrace.com/docs/dynatrace-api/environment-api/events-v2/post-event
80
- """
81
-
82
- CUSTOM_INFO = "CUSTOM_INFO"
83
- CUSTOM_ALERT = "CUSTOM_ALERT"
84
- CUSTOM_ANNOTATION = "CUSTOM_ANNOTATION"
85
- CUSTOM_CONFIGURATION = "CUSTOM_CONFIGURATION"
86
- CUSTOM_DEPLOYMENT = "CUSTOM_DEPLOYMENT"
87
- MARKED_FOR_TERMINATION = "MARKED_FOR_TERMINATION"
88
- ERROR_EVENT = "ERROR_EVENT"
89
- AVAILABILITY_EVENT = "AVAILABILITY_EVENT"
90
- RESOURCE_CONTENTION_EVENT = "RESOURCE_CONTENTION_EVENT"
91
-
92
-
93
- class CountMetricRegistrationEntry(NamedTuple):
94
- metric_key: str
95
- aggregation_mode: AggregationMode
96
- dimensions_list: list[str]
97
-
98
- @staticmethod
99
- def make_list(metric_key: str, dimensions_list: List[str]):
100
- """Build an entry that uses defined list of dimensions for aggregation.
101
-
102
- Args:
103
- metric_key: Metric key in string.
104
- dimensions_list: List of dimensions.
105
- """
106
- return CountMetricRegistrationEntry(metric_key, AggregationMode.LIST, dimensions_list)
107
-
108
- @staticmethod
109
- def make_all(metric_key: str):
110
- """Build an entry that uses all mint dimensions for aggregation.
111
-
112
- Args:
113
- metric_key: Metric key in string.
114
- """
115
- return CountMetricRegistrationEntry(metric_key, AggregationMode.ALL, [])
116
-
117
- @staticmethod
118
- def make_none(metric_key: str):
119
- """Build an entry that uses none of mint dimensions for aggregation.
120
-
121
- Args:
122
- metric_key: Metric key in string.
123
- """
124
- return CountMetricRegistrationEntry(metric_key, AggregationMode.NONE, [])
125
-
126
- def registration_items_dict(self):
127
- result = {"aggregation_mode": self.aggregation_mode.value}
128
- if self.aggregation_mode == AggregationMode.LIST:
129
- result["dimensions_list"] = self.dimensions_list
130
- return result
131
- else:
132
- return result
133
-
134
-
135
- def _add_sfm_metric(metric: Metric, sfm_metrics: Optional[List[Metric]] = None):
136
- if sfm_metrics is None:
137
- sfm_metrics = []
138
- metric.validate()
139
- sfm_metrics.append(metric)
140
-
141
-
142
- class Extension:
143
- """Base class for Python extensions.
144
-
145
- Attributes:
146
- logger: Embedded logger object for the extension.
147
- """
148
-
149
- _instance: ClassVar = None
150
- schedule_decorators: ClassVar = []
151
-
152
- def __new__(cls):
153
- if Extension._instance is None:
154
- Extension._instance = super(__class__, cls).__new__(cls)
155
- return Extension._instance
156
-
157
- def __init__(self) -> None:
158
- # do not initialize already created singleton
159
- if hasattr(self, "logger"):
160
- return
161
-
162
- # TODO - Move the logging implementation to its own file
163
- # TODO - Add sfm logging
164
- self.logger = extension_logger
165
-
166
- self.extension_config: str = ""
167
- self._feature_sets: dict[str, list[str]] = {}
168
-
169
- # Useful metadata, populated once the extension is started
170
- self.extension_name = "" # Needs to be set by the developer if they so decide
171
- self.extension_version = ""
172
- self.monitoring_config_name = ""
173
- self._task_id = "development_task_id"
174
- self._monitoring_config_id = "development_config_id"
175
-
176
- # The user can override default EEC enrichment for logs
177
- self.log_event_enrichment = True
178
-
179
- # The Communication client
180
- self._client: CommunicationClient = None # type: ignore
181
-
182
- # Set to true when --fastcheck is passed as a parameter
183
- self._is_fastcheck: bool = True
184
-
185
- # If this is true, we are running locally during development
186
- self._running_in_sim: bool = False
187
-
188
- # Response from EEC to /alive/ requests
189
- self._runtime_properties: RuntimeProperties = RuntimeProperties({})
190
-
191
- # The time difference between the local machine and the cluster time, used to sync callbacks with cluster
192
- self._cluster_time_diff: int = 0
193
-
194
- # Optional callback to be invoked during the fastcheck
195
- self._fast_check_callback: Optional[Callable[[ActivationConfig, str], Status]] = None
196
-
197
- # List of all scheduled callbacks we must run
198
- self._scheduled_callbacks: List[WrappedCallback] = []
199
- self._scheduled_callbacks_before_run: List[WrappedCallback] = []
200
-
201
- # Internal callbacks results, used to report statuses
202
- self._internal_callbacks_results: Dict[str, Status] = {}
203
- self._internal_callbacks_results_lock: Lock = Lock()
204
-
205
- # Running callbacks, used to get the callback info when reporting metrics
206
- self._running_callbacks: Dict[int, WrappedCallback] = {}
207
- self._running_callbacks_lock: Lock = Lock()
208
-
209
- self._scheduler = sched.scheduler(time.time, time.sleep)
210
-
211
- # Executors for the callbacks and internal methods
212
- self._callbacks_executor = ThreadPoolExecutor(max_workers=CALLBACKS_THREAD_POOL_SIZE)
213
- self._internal_executor = ThreadPoolExecutor(max_workers=INTERNAL_THREAD_POOL_SIZE)
214
-
215
- # Extension metrics
216
- self._metrics_lock = RLock()
217
- self._metrics: List[str] = []
218
-
219
- # Self monitoring metrics
220
- self._sfm_metrics_lock = Lock()
221
- self._callbackSfmReport: Dict[str, WrappedCallback] = {}
222
-
223
- # Count metric delta signals
224
- self._delta_signal_buffer: set[str] = set()
225
- self._registered_count_metrics: set[str] = set()
226
-
227
- # Self tech rule
228
- self._techrule = ""
229
-
230
- # Error message from caught exception in self.initialize()
231
- self._initialization_error: str = ""
232
-
233
- self._parse_args()
234
-
235
- for function, interval, args, activation_type in Extension.schedule_decorators:
236
- params = (self,)
237
- if args is not None:
238
- params = params + args
239
- self.schedule(function, interval, params, activation_type)
240
-
241
- api_logger.info("-----------------------------------------------------")
242
- api_logger.info(f"Starting {self.__class__} {self.extension_name}, version: {self.get_version()}")
243
-
244
- @property
245
- def is_helper(self) -> bool:
246
- """Internal property used by the EEC."""
247
-
248
- return False
249
-
250
- @property
251
- def task_id(self) -> str:
252
- """Internal property used by the EEC."""
253
-
254
- return self._task_id
255
-
256
- @property
257
- def monitoring_config_id(self) -> str:
258
- """Internal property used by the EEC.
259
-
260
- Represents a unique identifier of the monitoring configuration.
261
- that is assigned to this particular extension instance.
262
- """
263
-
264
- return self._monitoring_config_id
265
-
266
- def run(self):
267
- """Launch the extension instance.
268
-
269
- Calling this method starts the main loop of the extension.
270
-
271
- This method must be invoked once to start the extension,
272
-
273
- if `--fastcheck` is set, the extension will run in fastcheck mode,
274
- otherwise the main loop is started, which periodically runs:
275
-
276
- * The scheduled callbacks
277
- * The heartbeat method
278
- * The metrics publisher method
279
- """
280
-
281
- self._setup_signal_handlers()
282
- if self._is_fastcheck:
283
- return self._run_fastcheck()
284
- self._start_extension_loop()
285
-
286
- def _setup_signal_handlers(self):
287
- if sys.platform == "win32":
288
- signal.signal(signal.SIGBREAK, self._shutdown_signal_handler)
289
- signal.signal(signal.SIGINT, self._shutdown_signal_handler)
290
-
291
- def _shutdown_signal_handler(self, sig, frame): # noqa: ARG002
292
- api_logger.info(f"{signal.Signals(sig).name} captured. Flushing metrics and exiting...")
293
- self.on_shutdown()
294
- self._send_metrics()
295
- self._send_sfm_metrics()
296
- sys.exit(0)
297
-
298
- def on_shutdown(self):
299
- """Callback method to be invoked when the extension is shutting down.
300
-
301
- Called when extension exits after it has received shutdown signal from EEC
302
- This is executed before metrics are flushed to EEC
303
- """
304
- pass
305
-
306
- def _schedule_callback(self, callback: WrappedCallback):
307
- if callback.activation_type is not None and callback.activation_type != self.activation_config.type:
308
- api_logger.info(
309
- f"Skipping {callback} with activation type {callback.activation_type} because it is not {self.activation_config.type}"
310
- )
311
- return
312
-
313
- api_logger.debug(f"Scheduling callback {callback}")
314
-
315
- # These properties are updated after the extension starts
316
- # TODO - These should be part of an ext singleton object instead
317
- callback.cluster_time_diff = self._cluster_time_diff
318
- callback.running_in_sim = self._running_in_sim
319
- self._scheduled_callbacks.append(callback)
320
- self._scheduler.enter(callback.initial_wait_time(), 1, self._callback_iteration, (callback,))
321
-
322
- def schedule(
323
- self,
324
- callback: Callable,
325
- interval: Union[timedelta, int],
326
- args: Optional[tuple] = None,
327
- activation_type: Optional[ActivationType] = None,
328
- ) -> None:
329
- """Schedule a method to be executed periodically.
330
-
331
- The callback method will be periodically invoked in a separate thread.
332
- The callback method is always immediately scheduled for execution.
333
-
334
- Args:
335
- callback: The callback method to be invoked
336
- interval: The time interval between invocations, can be a timedelta object,
337
- or an int representing the number of seconds
338
- args: Arguments to the callback, if any
339
- activation_type: Optional activation type when this callback should run,
340
- can be 'ActivationType.LOCAL' or 'ActivationType.REMOTE'
341
- """
342
-
343
- if isinstance(interval, int):
344
- interval = timedelta(seconds=interval)
345
-
346
- callback = WrappedCallback(interval, callback, api_logger, args, activation_type=activation_type)
347
- if self._is_fastcheck:
348
- self._scheduled_callbacks_before_run.append(callback)
349
- else:
350
- self._schedule_callback(callback)
351
-
352
- def query(self):
353
- """Callback to be executed every minute by default.
354
-
355
- Optional method that can be implemented by subclasses.
356
- The query method is always scheduled to run every minute.
357
- """
358
- pass
359
-
360
- def initialize(self):
361
- """Callback to be executed when the extension starts.
362
-
363
- Called once after the extension starts and the processes arguments are parsed.
364
- Sometimes there are tasks the user needs to do that must happen before runtime,
365
- but after the activation config has been received, example: Setting the schedule frequency
366
- based on the user input on the monitoring configuration, this can be done on this method
367
- """
368
- pass
369
-
370
- def fastcheck(self) -> Status:
371
- """Callback executed when extension is launched.
372
-
373
- Called if the extension is run in the `fastcheck` mode. Only invoked for remote
374
- extensions.
375
- This method is not called if fastcheck callback was already registered with
376
- Extension.register_fastcheck().
377
-
378
- Returns:
379
- Status with optional message whether the fastcheck succeed or failed.
380
- """
381
- return Status(StatusValue.OK)
382
-
383
- def register_fastcheck(self, fast_check_callback: Callable[[ActivationConfig, str], Status]):
384
- """Registers fastcheck callback that is executed in the `fastcheck` mode.
385
-
386
- Extension.fastcheck() is not called if fastcheck callback is registered with this method
387
-
388
- Args:
389
- fast_check_callback: callable called with ActivationConfig and
390
- extension_config arguments. Must return the Status with optional message
391
- whether the fastcheck succeed or failed.
392
- """
393
- if self._fast_check_callback:
394
- api_logger.error("More than one function assigned to fastcheck, last registered one was kept.")
395
-
396
- self._fast_check_callback = fast_check_callback
397
-
398
- def _register_count_metrics(self, *count_metric_entries: CountMetricRegistrationEntry) -> None:
399
- """Send a count metric registration request to EEC.
400
-
401
- Args:
402
- count_metric_entries: CountMetricRegistrationEntry objects for each count metric to register
403
- """
404
- json_pattern = {
405
- metric_entry.metric_key: metric_entry.registration_items_dict() for metric_entry in count_metric_entries
406
- }
407
- self._client.register_count_metrics(json_pattern)
408
-
409
- def _send_count_delta_signal(self, metric_keys: set[str], force: bool = True) -> None:
410
- """Send calculate-delta signal to EEC monotonic converter.
411
-
412
- Args:
413
- metric_keys: List with metrics for which we want to calculate deltas
414
- force: If true, it forces the metrics from cache to be pushed into EEC and then delta signal request is
415
- sent. Otherwise, it puts delta signal request in cache and request is sent after nearest (in time) sending
416
- metrics to EEC event
417
- """
418
-
419
- with self._metrics_lock:
420
- if not force:
421
- for key in metric_keys:
422
- self._delta_signal_buffer.add(key)
423
- return
424
-
425
- self._send_metrics()
426
- self._client.send_count_delta_signal(metric_keys)
427
- self._delta_signal_buffer = {
428
- metric_key for metric_key in self._delta_signal_buffer if metric_key not in metric_keys
429
- }
430
-
431
- def report_metric(
432
- self,
433
- key: str,
434
- value: Union[float, str, int, SummaryStat],
435
- dimensions: Optional[Dict[str, str]] = None,
436
- techrule: Optional[str] = None,
437
- timestamp: Optional[datetime] = None,
438
- metric_type: MetricType = MetricType.GAUGE,
439
- ) -> None:
440
- """Report a metric.
441
-
442
- Metric is sent to EEC using an HTTP request and MINT protocol. EEC then
443
- sends the metrics to the tenant.
444
-
445
- By default, it reports a gauge metric.
446
-
447
- Args:
448
- key: The metric key, must follow the MINT specification
449
- value: The metric value, can be a simple value or a SummaryStat
450
- dimensions: A dictionary of dimensions
451
- techrule: The technology rule string set by self.techrule setter.
452
- timestamp: The timestamp of the metric, defaults to the current time
453
- metric_type: The type of the metric, defaults to MetricType.GAUGE
454
- """
455
-
456
- if techrule:
457
- if not dimensions:
458
- dimensions = {}
459
- if "dt.techrule.id" not in dimensions:
460
- dimensions["dt.techrule.id"] = techrule
461
-
462
- if metric_type == MetricType.COUNT and timestamp is None:
463
- # We must report a timestamp for count metrics
464
- timestamp = datetime.now()
465
-
466
- metric = Metric(key=key, value=value, dimensions=dimensions, metric_type=metric_type, timestamp=timestamp)
467
- self._add_metric(metric)
468
-
469
- def report_mint_lines(self, lines: List[str]) -> None:
470
- """Report mint lines using the MINT protocol
471
-
472
- Examples:
473
- Metric lines must comply with the MINT format.
474
-
475
- >>> self.report_mint_lines(["my_metric 1", "my_other_metric 2"])
476
-
477
- Args:
478
- lines: A list of mint lines
479
- """
480
- self._add_mint_lines(lines)
481
-
482
- def report_event(
483
- self,
484
- title: str,
485
- description: str,
486
- properties: Optional[dict] = None,
487
- timestamp: Optional[datetime] = None,
488
- severity: Union[Severity, str] = Severity.INFO,
489
- ) -> None:
490
- """Report an event using log ingest.
491
-
492
- Args:
493
- title: The title of the event
494
- description: The description of the event
495
- properties: A dictionary of extra event properties
496
- timestamp: The timestamp of the event, defaults to the current time
497
- severity: The severity of the event, defaults to Severity.INFO
498
- """
499
- if timestamp is None:
500
- timestamp = datetime.now(tz=timezone.utc)
501
-
502
- if properties is None:
503
- properties = {}
504
-
505
- event = {
506
- "content": f"{title}\n{description}",
507
- "title": title,
508
- "description": description,
509
- "timestamp": timestamp.strftime(RFC_3339_FORMAT),
510
- "severity": severity.value if isinstance(severity, Severity) else severity,
511
- **self._metadata,
512
- **properties,
513
- }
514
- self._send_events(event)
515
-
516
- def report_dt_event(
517
- self,
518
- event_type: DtEventType,
519
- title: str,
520
- start_time: Optional[int] = None,
521
- end_time: Optional[int] = None,
522
- timeout: Optional[int] = None,
523
- entity_selector: Optional[str] = None,
524
- properties: Optional[dict[str, str]] = None,
525
- ) -> None:
526
- """
527
- Reports an event using the v2 event ingest API.
528
-
529
- Unlike ``report_event``, this directly raises an event or even a problem
530
- based on the specified ``event_type``.
531
-
532
- Note:
533
- For reference see: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/events-v2/post-event
534
-
535
- Args:
536
- event_type: The event type chosen from type Enum (required)
537
- title: The title of the event (required)
538
- start_time: The start time of event in UTC ms, if not set, current timestamp (optional)
539
- end_time: The end time of event in UTC ms, if not set, current timestamp + timeout (optional)
540
- timeout: The timeout of event in minutes, if not set, 15 (optional)
541
- entity_selector: The entity selector, if not set, the event is associated with environment entity (optional)
542
- properties: A map of event properties (optional)
543
- """
544
- event: Dict[str, Any] = {"eventType": event_type, "title": title}
545
- if start_time:
546
- event["startTime"] = start_time
547
- if end_time:
548
- event["endTime"] = end_time
549
- if timeout:
550
- event["timeout"] = timeout
551
- if entity_selector:
552
- event["entitySelector"] = entity_selector
553
- if properties:
554
- event["properties"] = properties
555
-
556
- self._send_dt_event(event)
557
-
558
- def report_dt_event_dict(self, event: dict):
559
- """Report an event using event ingest API with provided dictionary.
560
-
561
- Note:
562
- For reference see: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/events-v2/post-event
563
-
564
- Format of the event dictionary::
565
-
566
- {
567
- "type": "object",
568
- "required": ["eventType", "title"],
569
- "properties": {
570
- "eventType": {
571
- "type": "string",
572
- "enum": [
573
- "CUSTOM_INFO",
574
- "CUSTOM_ANNOTATION",
575
- "CUSTOM_CONFIGURATION",
576
- "CUSTOM_DEPLOYMENT",
577
- "MARKED_FOR_TERMINATION",
578
- "ERROR_EVENT",
579
- "AVAILABILITY_EVENT",
580
- "PERFORMANCE_EVENT",
581
- "RESOURCE_CONTENTION_EVENT",
582
- "CUSTOM_ALERT"
583
- ]
584
- },
585
- "title": {
586
- "type": "string",
587
- "minLength": 1
588
- },
589
- "startTime": {"type": "integer"},
590
- "endTime": {"type": "integer"},
591
- "timeout": {"type": "integer"},
592
- "entitySelector": {"type": "string"},
593
- "properties": {
594
- "type": "object",
595
- "patternProperties": {
596
- "^.*$": {"type": "string"}
597
- }
598
- }
599
- }
600
- }
601
- """
602
-
603
- if "eventType" not in event or "title" not in event:
604
- raise ValueError('"eventType" not present' if "eventType" not in event else '"title" not present in event')
605
- for key, value in event.items():
606
- if DT_EVENT_SCHEMA[key] is None:
607
- msg = f'invalid member: "{key}"'
608
- raise ValueError(msg)
609
- if key == "eventType" and value not in list(DtEventType):
610
- msg = f"Event type must be a DtEventType enum value, got: {value}"
611
- raise ValueError(msg)
612
- if key == "properties":
613
- for prop_key, prop_val in event[key].items():
614
- if not isinstance(prop_key, str) or not isinstance(prop_val, str):
615
- msg = f'invalid "properties" member: {prop_key}: {prop_val}, required: "str": str'
616
- raise ValueError(msg)
617
- self._send_dt_event(event)
618
-
619
- def report_log_event(self, log_event: dict):
620
- """Report a custom log event using log ingest.
621
-
622
- Note:
623
- See reference: https://www.dynatrace.com/support/help/shortlink/log-monitoring-log-data-ingestion
624
-
625
- Args:
626
- log_event: The log event dictionary.
627
- """
628
- self._send_events(log_event)
629
-
630
- def report_log_events(self, log_events: List[dict]):
631
- """Report a list of custom log events using log ingest.
632
-
633
- Args:
634
- log_events: The list of log events
635
- """
636
- self._send_events(log_events)
637
-
638
- def report_log_lines(self, log_lines: List[Union[str, bytes]]):
639
- """Report a list of log lines using log ingest
640
-
641
- Args:
642
- log_lines: The list of log lines
643
- """
644
- events = [{"content": line} for line in log_lines]
645
- self._send_events(events)
646
-
647
- @property
648
- def enabled_feature_sets(self) -> dict[str, list[str]]:
649
- """Map of enabled feautre sets and corresponding metrics.
650
-
651
- Returns:
652
- Dictionary containing enabled feature sets with corresponding
653
- metrics defined in ``extension.yaml``.
654
- """
655
- return {
656
- feature_set_name: metric_keys
657
- for feature_set_name, metric_keys in self._feature_sets.items()
658
- if feature_set_name in self.activation_config.feature_sets or feature_set_name == "default"
659
- }
660
-
661
- @property
662
- def enabled_feature_sets_names(self) -> list[str]:
663
- """Names of enabled feature sets.
664
-
665
- Returns:
666
- List containing names of enabled feature sets.
667
- """
668
- return list(self.enabled_feature_sets.keys())
669
-
670
- @property
671
- def enabled_feature_sets_metrics(self) -> list[str]:
672
- """Enabled metrics.
673
-
674
- Returns:
675
- List of all metric keys from enabled feature sets
676
- """
677
- return list(chain(*self.enabled_feature_sets.values()))
678
-
679
- def _parse_args(self):
680
- parser = ArgumentParser(description="Python extension parameters")
681
-
682
- # Production parameters, these are passed by the EEC
683
- parser.add_argument("--dsid", required=False, default=None)
684
- parser.add_argument("--url", required=False)
685
- parser.add_argument("--idtoken", required=False)
686
- parser.add_argument(
687
- "--loglevel",
688
- help="Set extension log level. Info is default.",
689
- type=str,
690
- choices=["debug", "info"],
691
- default="info",
692
- )
693
- parser.add_argument("--fastcheck", action="store_true", default=False)
694
- parser.add_argument("--monitoring_config_id", required=False, default=None)
695
- parser.add_argument("--local-ingest", action="store_true", default=False)
696
- parser.add_argument("--local-ingest-port", required=False, default=14499)
697
-
698
- # Debug parameters, these are used when running the extension locally
699
- parser.add_argument("--extensionconfig", required=False, default=None)
700
- parser.add_argument("--activationconfig", required=False, default="activation.json")
701
-
702
- args, unknown = parser.parse_known_args()
703
- self._is_fastcheck = args.fastcheck
704
- if args.dsid is None:
705
- # DEV mode
706
- self._running_in_sim = True
707
- self._client = DebugClient(
708
- args.activationconfig, args.extensionconfig, api_logger, args.local_ingest, args.local_ingest_port
709
- )
710
- RuntimeProperties.set_default_log_level(args.loglevel)
711
- else:
712
- # EEC mode
713
- self._client = HttpClient(args.url, args.dsid, args.idtoken, api_logger)
714
- self._task_id = args.dsid
715
- self._monitoring_config_id = args.monitoring_config_id
716
- api_logger.info(f"DSID = {self.task_id}, monitoring config id = {self._monitoring_config_id}")
717
-
718
- self.activation_config = ActivationConfig(self._client.get_activation_config())
719
- self.extension_config = self._client.get_extension_config()
720
- self._feature_sets = self._client.get_feature_sets()
721
-
722
- self.monitoring_config_name = self.activation_config.description
723
- self.extension_version = self.activation_config.version
724
-
725
- if not self._is_fastcheck:
726
- try:
727
- self.initialize()
728
- if not self.is_helper:
729
- self.schedule(self.query, timedelta(minutes=1))
730
- except Exception as e:
731
- msg = f"Error running self.initialize {self}: {e!r}"
732
- api_logger.exception(msg)
733
- self._client.send_status(Status(StatusValue.GENERIC_ERROR, msg))
734
- self._initialization_error = msg
735
- raise e
736
-
737
- @property
738
- def _metadata(self) -> dict:
739
- return {
740
- "dt.extension.config.id": self._runtime_properties.extconfig,
741
- "dt.extension.ds": DATASOURCE_TYPE,
742
- "dt.extension.version": self.extension_version,
743
- "dt.extension.name": self.extension_name,
744
- "monitoring.configuration": self.monitoring_config_name,
745
- }
746
-
747
- def _run_fastcheck(self):
748
- api_logger.info(f"Running fastcheck for monitoring configuration '{self.monitoring_config_name}'")
749
- try:
750
- if self._fast_check_callback:
751
- status = self._fast_check_callback(self.activation_config, self.extension_config)
752
- api_logger.info(f"Sending fastcheck status: {status}")
753
- self._client.send_status(status)
754
- return
755
-
756
- status = self.fastcheck()
757
- api_logger.info(f"Sending fastcheck status: {status}")
758
- self._client.send_status(status)
759
- except Exception as e:
760
- status = Status(StatusValue.GENERIC_ERROR, f"Python datasource fastcheck error: {e!r}")
761
- api_logger.error(f"Error running fastcheck {self}: {e!r}")
762
- self._client.send_status(status)
763
- raise
764
-
765
- def _run_callback(self, callback: WrappedCallback):
766
- if not callback.running:
767
- # Add the callback to the list of running callbacks
768
- with self._running_callbacks_lock:
769
- current_thread_id = threading.get_ident()
770
- self._running_callbacks[current_thread_id] = callback
771
-
772
- callback(self.activation_config, self.extension_config)
773
-
774
- with self._sfm_metrics_lock:
775
- self._callbackSfmReport[callback.name()] = callback
776
- # Remove the callback from the list of running callbacks
777
- with self._running_callbacks_lock:
778
- self._running_callbacks.pop(current_thread_id, None)
779
-
780
- def _callback_iteration(self, callback: WrappedCallback):
781
- self._callbacks_executor.submit(self._run_callback, callback)
782
- self._scheduler.enter(callback.interval.total_seconds(), 1, self._callback_iteration, (callback,))
783
-
784
- def _start_extension_loop(self):
785
- api_logger.debug(f"Starting main loop for monitoring configuration: '{self.monitoring_config_name}'")
786
-
787
- # These were scheduled before the extension started, schedule them now
788
- for callback in self._scheduled_callbacks_before_run:
789
- self._schedule_callback(callback)
790
- self._heartbeat_iteration()
791
- self._metrics_iteration()
792
- self._sfm_metrics_iteration()
793
- self._timediff_iteration()
794
- self._scheduler.run()
795
-
796
- def _timediff_iteration(self):
797
- self._internal_executor.submit(self._update_cluster_time_diff)
798
- self._scheduler.enter(TIME_DIFF_INTERVAL.total_seconds(), 1, self._timediff_iteration)
799
-
800
- def _heartbeat_iteration(self):
801
- self._internal_executor.submit(self._heartbeat)
802
- self._scheduler.enter(HEARTBEAT_INTERVAL.total_seconds(), 1, self._heartbeat_iteration)
803
-
804
- def _metrics_iteration(self):
805
- self._internal_executor.submit(self._send_metrics)
806
- self._scheduler.enter(METRIC_SENDING_INTERVAL.total_seconds(), 1, self._metrics_iteration)
807
-
808
- def _sfm_metrics_iteration(self):
809
- self._internal_executor.submit(self._send_sfm_metrics)
810
- self._scheduler.enter(SFM_METRIC_SENDING_INTERVAL.total_seconds(), 1, self._sfm_metrics_iteration)
811
-
812
- def _send_metrics(self):
813
- # TODO - we might need to check size and number of lines before sending
814
- # Maybe break it down into multiple packets
815
- with self._metrics_lock and self._internal_callbacks_results_lock:
816
- if self._metrics:
817
- number_of_metrics = len(self._metrics)
818
- responses = self._client.send_metrics(self._metrics)
819
-
820
- self._internal_callbacks_results[self._send_metrics.__name__] = Status(StatusValue.OK)
821
- lines_invalid = sum(response.lines_invalid for response in responses)
822
- if lines_invalid > 0:
823
- message = f"{lines_invalid} invalid metric lines found"
824
- self._internal_callbacks_results[self._send_metrics.__name__] = Status(
825
- StatusValue.GENERIC_ERROR, message
826
- )
827
-
828
- api_logger.info(f"Sent {number_of_metrics} metric lines to EEC: {responses}")
829
- self._metrics = []
830
-
831
- def _prepare_sfm_metrics(self) -> List[str]:
832
- """Prepare self monitoring metrics.
833
-
834
- Builds the list of mint metric lines to send as self monitoring metrics.
835
- """
836
-
837
- sfm_metrics: List[Metric] = []
838
- sfm_dimensions = {"dt.extension.config.id": self.monitoring_config_id}
839
- _add_sfm_metric(
840
- SfmMetric("threads", active_count(), sfm_dimensions, client_facing=True, metric_type=MetricType.DELTA),
841
- sfm_metrics,
842
- )
843
-
844
- for name, callback in self._callbackSfmReport.items():
845
- sfm_dimensions = {"callback": name, "dt.extension.config.id": self.monitoring_config_id}
846
- _add_sfm_metric(
847
- SfmMetric(
848
- "execution.time",
849
- f"{callback.duration_interval_total:.4f}",
850
- sfm_dimensions,
851
- client_facing=True,
852
- metric_type=MetricType.GAUGE,
853
- ),
854
- sfm_metrics,
855
- )
856
- _add_sfm_metric(
857
- SfmMetric(
858
- "execution.total.count",
859
- callback.executions_total,
860
- sfm_dimensions,
861
- client_facing=True,
862
- metric_type=MetricType.DELTA,
863
- ),
864
- sfm_metrics,
865
- )
866
- _add_sfm_metric(
867
- SfmMetric(
868
- "execution.count",
869
- callback.executions_per_interval,
870
- sfm_dimensions,
871
- client_facing=True,
872
- metric_type=MetricType.DELTA,
873
- ),
874
- sfm_metrics,
875
- )
876
- _add_sfm_metric(
877
- SfmMetric(
878
- "execution.ok.count",
879
- callback.ok_count,
880
- sfm_dimensions,
881
- client_facing=True,
882
- metric_type=MetricType.DELTA,
883
- ),
884
- sfm_metrics,
885
- )
886
- _add_sfm_metric(
887
- SfmMetric(
888
- "execution.timeout.count",
889
- callback.timeouts_count,
890
- sfm_dimensions,
891
- client_facing=True,
892
- metric_type=MetricType.DELTA,
893
- ),
894
- sfm_metrics,
895
- )
896
- _add_sfm_metric(
897
- SfmMetric(
898
- "execution.exception.count",
899
- callback.exception_count,
900
- sfm_dimensions,
901
- client_facing=True,
902
- metric_type=MetricType.DELTA,
903
- ),
904
- sfm_metrics,
905
- )
906
- callback.clear_sfm_metrics()
907
- return [metric.to_mint_line() for metric in sfm_metrics]
908
-
909
- def _send_sfm_metrics(self):
910
- with self._sfm_metrics_lock:
911
- lines = self._prepare_sfm_metrics()
912
- # Flushes the cache of metrics, maybe we should only flush if they were successfully sent
913
- self._callbackSfmReport.clear()
914
- response = self._client.send_sfm_metrics(lines)
915
-
916
- with self._internal_callbacks_results_lock:
917
- self._internal_callbacks_results[self._send_sfm_metrics.__name__] = Status(StatusValue.OK)
918
- if response.lines_invalid > 0:
919
- message = f"{response.lines_invalid} invalid metric lines found"
920
- self._internal_callbacks_results[self._send_sfm_metrics.__name__] = Status(
921
- StatusValue.GENERIC_ERROR, message
922
- )
923
-
924
- def _build_current_status(self):
925
- overall_status = Status(StatusValue.OK)
926
-
927
- if self._initialization_error:
928
- overall_status.status = StatusValue.GENERIC_ERROR
929
- overall_status.message = self._initialization_error
930
- return overall_status
931
-
932
- internal_callback_error = False
933
- messages = []
934
- with self._internal_callbacks_results_lock:
935
- for callback, result in self._internal_callbacks_results.items():
936
- if result.is_error():
937
- internal_callback_error = True
938
- overall_status.status = result.status
939
- messages.append(f"{callback}: {result.message}")
940
- if internal_callback_error:
941
- overall_status.message = "\n".join(messages)
942
- return overall_status
943
-
944
- for callback in self._scheduled_callbacks:
945
- overall_status.timestamp = int(callback.get_adjusted_metric_timestamp().timestamp() * 1000)
946
- if callback.status.is_error():
947
- overall_status.status = callback.status.status
948
- messages.append(f"{callback}: {callback.status.message}")
949
-
950
- overall_status.message = "\n".join(messages)
951
- return overall_status
952
-
953
- def _update_cluster_time_diff(self):
954
- self._cluster_time_diff = self._client.get_cluster_time_diff()
955
- for callback in self._scheduled_callbacks:
956
- callback.cluster_time_diff = self._cluster_time_diff
957
-
958
- def _heartbeat(self):
959
- response = bytes("not set", "utf-8")
960
- try:
961
- overall_status = self._build_current_status()
962
- response = self._client.send_status(overall_status)
963
- self._runtime_properties = RuntimeProperties(response)
964
- except Exception as e:
965
- api_logger.error(f"Heartbeat failed because {e}, response {response}", exc_info=True)
966
-
967
- def __del__(self):
968
- self._callbacks_executor.shutdown()
969
- self._internal_executor.shutdown()
970
-
971
- def _add_metric(self, metric: Metric):
972
- metric.validate()
973
-
974
- with self._running_callbacks_lock:
975
- current_thread_id = threading.get_ident()
976
- current_callback = self._running_callbacks.get(current_thread_id)
977
-
978
- if current_callback is not None and metric.timestamp is None:
979
- # Adjust the metric timestamp according to the callback start time
980
- # If the user manually set a metric timestamp, don't adjust it
981
- metric.timestamp = current_callback.get_adjusted_metric_timestamp()
982
- elif current_callback is None and metric.timestamp is None:
983
- api_logger.debug(
984
- f"Metric {metric} was added by unknown thread {current_thread_id}, cannot adjust the timestamp"
985
- )
986
-
987
- with self._metrics_lock:
988
- self._metrics.append(metric.to_mint_line())
989
-
990
- def _add_mint_lines(self, lines: List[str]):
991
- with self._metrics_lock:
992
- self._metrics.extend(lines)
993
-
994
- def _send_events_internal(self, events: Union[dict, List[dict]]):
995
- response = self._client.send_events(events, self.log_event_enrichment)
996
- with self._internal_callbacks_results_lock:
997
- self._internal_callbacks_results[self._send_events.__name__] = Status(StatusValue.OK)
998
- if not response or "error" not in response or "message" not in response["error"]:
999
- return
1000
- self._internal_callbacks_results[self._send_events.__name__] = Status(
1001
- StatusValue.GENERIC_ERROR, response["error"]["message"]
1002
- )
1003
-
1004
- def _send_events(self, events: Union[dict, List[dict]]):
1005
- self._internal_executor.submit(self._send_events_internal, events)
1006
-
1007
- def _send_dt_event(self, event: dict[str, str | int | dict[str, str]]):
1008
- self._client.send_dt_event(event)
1009
-
1010
- def get_version(self) -> str:
1011
- """Return the version of extensions sdk library."""
1012
-
1013
- return __version__
1014
-
1015
- @property
1016
- def techrule(self) -> str:
1017
- """Internal property used by the EEC."""
1018
-
1019
- return self._techrule
1020
-
1021
- @techrule.setter
1022
- def techrule(self, value):
1023
- self._techrule = value
1024
-
1025
- def get_activation_config(self) -> ActivationConfig:
1026
- """Retrieve the activation config.
1027
-
1028
- Represents activation configuration assigned to this particular
1029
- extension instance.
1030
-
1031
- Returns:
1032
- ActivationConfig object.
1033
- """
1034
- return self.activation_config
1
+ # SPDX-FileCopyrightText: 2023-present Dynatrace LLC
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import logging
6
+ import sched
7
+ import signal
8
+ import sys
9
+ import threading
10
+ import time
11
+ from argparse import ArgumentParser
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from datetime import datetime, timedelta, timezone
14
+ from enum import Enum
15
+ from itertools import chain
16
+ from threading import Lock, RLock, active_count
17
+ from typing import Any, Callable, ClassVar, Dict, List, NamedTuple, Optional, Union
18
+
19
+ from ..__about__ import __version__
20
+ from .activation import ActivationConfig, ActivationType
21
+ from .callback import WrappedCallback
22
+ from .communication import CommunicationClient, DebugClient, HttpClient, Status, StatusValue
23
+ from .event import Severity
24
+ from .metric import Metric, MetricType, SfmMetric, SummaryStat
25
+ from .runtime import RuntimeProperties
26
+
27
+ HEARTBEAT_INTERVAL = timedelta(seconds=30)
28
+ METRIC_SENDING_INTERVAL = timedelta(seconds=30)
29
+ SFM_METRIC_SENDING_INTERVAL = timedelta(seconds=60)
30
+ TIME_DIFF_INTERVAL = timedelta(seconds=60)
31
+
32
+ CALLBACKS_THREAD_POOL_SIZE = 100
33
+ INTERNAL_THREAD_POOL_SIZE = 20
34
+
35
+ RFC_3339_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
36
+ DATASOURCE_TYPE = "python"
37
+
38
+ logging.raiseExceptions = False
39
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s (%(threadName)s): %(message)s")
40
+ error_handler = logging.StreamHandler()
41
+ error_handler.addFilter(lambda record: record.levelno >= logging.ERROR)
42
+ error_handler.setFormatter(formatter)
43
+ std_handler = logging.StreamHandler(sys.stdout)
44
+ std_handler.addFilter(lambda record: record.levelno < logging.ERROR)
45
+ std_handler.setFormatter(formatter)
46
+ extension_logger = logging.getLogger(__name__)
47
+ extension_logger.setLevel(logging.INFO)
48
+ extension_logger.addHandler(error_handler)
49
+ extension_logger.addHandler(std_handler)
50
+
51
+ api_logger = logging.getLogger("api")
52
+ api_logger.setLevel(logging.INFO)
53
+ api_logger.addHandler(error_handler)
54
+ api_logger.addHandler(std_handler)
55
+
56
+ DT_EVENT_SCHEMA = {
57
+ "eventType": str,
58
+ "title": str,
59
+ "startTime": int,
60
+ "endTime": int,
61
+ "timeout": int,
62
+ "entitySelector": str,
63
+ "properties": dict,
64
+ }
65
+
66
+
67
+ class AggregationMode(Enum):
68
+ ALL = "include_all"
69
+ NONE = "include_none"
70
+ LIST = "include_list"
71
+
72
+
73
+ class DtEventType(str, Enum):
74
+ """Event type.
75
+
76
+ Note:
77
+ Official API v2 documentation:
78
+
79
+ https://docs.dynatrace.com/docs/dynatrace-api/environment-api/events-v2/post-event
80
+ """
81
+
82
+ CUSTOM_INFO = "CUSTOM_INFO"
83
+ CUSTOM_ALERT = "CUSTOM_ALERT"
84
+ CUSTOM_ANNOTATION = "CUSTOM_ANNOTATION"
85
+ CUSTOM_CONFIGURATION = "CUSTOM_CONFIGURATION"
86
+ CUSTOM_DEPLOYMENT = "CUSTOM_DEPLOYMENT"
87
+ MARKED_FOR_TERMINATION = "MARKED_FOR_TERMINATION"
88
+ ERROR_EVENT = "ERROR_EVENT"
89
+ AVAILABILITY_EVENT = "AVAILABILITY_EVENT"
90
+ RESOURCE_CONTENTION_EVENT = "RESOURCE_CONTENTION_EVENT"
91
+
92
+
93
+ class CountMetricRegistrationEntry(NamedTuple):
94
+ metric_key: str
95
+ aggregation_mode: AggregationMode
96
+ dimensions_list: list[str]
97
+
98
+ @staticmethod
99
+ def make_list(metric_key: str, dimensions_list: List[str]):
100
+ """Build an entry that uses defined list of dimensions for aggregation.
101
+
102
+ Args:
103
+ metric_key: Metric key in string.
104
+ dimensions_list: List of dimensions.
105
+ """
106
+ return CountMetricRegistrationEntry(metric_key, AggregationMode.LIST, dimensions_list)
107
+
108
+ @staticmethod
109
+ def make_all(metric_key: str):
110
+ """Build an entry that uses all mint dimensions for aggregation.
111
+
112
+ Args:
113
+ metric_key: Metric key in string.
114
+ """
115
+ return CountMetricRegistrationEntry(metric_key, AggregationMode.ALL, [])
116
+
117
+ @staticmethod
118
+ def make_none(metric_key: str):
119
+ """Build an entry that uses none of mint dimensions for aggregation.
120
+
121
+ Args:
122
+ metric_key: Metric key in string.
123
+ """
124
+ return CountMetricRegistrationEntry(metric_key, AggregationMode.NONE, [])
125
+
126
+ def registration_items_dict(self):
127
+ result = {"aggregation_mode": self.aggregation_mode.value}
128
+ if self.aggregation_mode == AggregationMode.LIST:
129
+ result["dimensions_list"] = self.dimensions_list
130
+ return result
131
+ else:
132
+ return result
133
+
134
+
135
+ def _add_sfm_metric(metric: Metric, sfm_metrics: Optional[List[Metric]] = None):
136
+ if sfm_metrics is None:
137
+ sfm_metrics = []
138
+ metric.validate()
139
+ sfm_metrics.append(metric)
140
+
141
+
142
+ class Extension:
143
+ """Base class for Python extensions.
144
+
145
+ Attributes:
146
+ logger: Embedded logger object for the extension.
147
+ """
148
+
149
+ _instance: ClassVar = None
150
+ schedule_decorators: ClassVar = []
151
+
152
+ def __new__(cls):
153
+ if Extension._instance is None:
154
+ Extension._instance = super(__class__, cls).__new__(cls)
155
+ return Extension._instance
156
+
157
+ def __init__(self) -> None:
158
+ # do not initialize already created singleton
159
+ if hasattr(self, "logger"):
160
+ return
161
+
162
+ self.logger = extension_logger
163
+
164
+ self.extension_config: str = ""
165
+ self._feature_sets: dict[str, list[str]] = {}
166
+
167
+ # Useful metadata, populated once the extension is started
168
+ self.extension_name = "" # Needs to be set by the developer if they so decide
169
+ self.extension_version = ""
170
+ self.monitoring_config_name = ""
171
+ self._task_id = "development_task_id"
172
+ self._monitoring_config_id = "development_config_id"
173
+
174
+ # The user can override default EEC enrichment for logs
175
+ self.log_event_enrichment = True
176
+
177
+ # The Communication client
178
+ self._client: CommunicationClient = None # type: ignore
179
+
180
+ # Set to true when --fastcheck is passed as a parameter
181
+ self._is_fastcheck: bool = True
182
+
183
+ # If this is true, we are running locally during development
184
+ self._running_in_sim: bool = False
185
+
186
+ # Response from EEC to /alive/ requests
187
+ self._runtime_properties: RuntimeProperties = RuntimeProperties({})
188
+
189
+ # The time difference between the local machine and the cluster time, used to sync callbacks with cluster
190
+ self._cluster_time_diff: int = 0
191
+
192
+ # Optional callback to be invoked during the fastcheck
193
+ self._fast_check_callback: Optional[Callable[[ActivationConfig, str], Status]] = None
194
+
195
+ # List of all scheduled callbacks we must run
196
+ self._scheduled_callbacks: List[WrappedCallback] = []
197
+ self._scheduled_callbacks_before_run: List[WrappedCallback] = []
198
+
199
+ # Internal callbacks results, used to report statuses
200
+ self._internal_callbacks_results: Dict[str, Status] = {}
201
+ self._internal_callbacks_results_lock: Lock = Lock()
202
+
203
+ # Running callbacks, used to get the callback info when reporting metrics
204
+ self._running_callbacks: Dict[int, WrappedCallback] = {}
205
+ self._running_callbacks_lock: Lock = Lock()
206
+
207
+ self._scheduler = sched.scheduler(time.time, time.sleep)
208
+
209
+ # Executors for the callbacks and internal methods
210
+ self._callbacks_executor = ThreadPoolExecutor(max_workers=CALLBACKS_THREAD_POOL_SIZE)
211
+ self._internal_executor = ThreadPoolExecutor(max_workers=INTERNAL_THREAD_POOL_SIZE)
212
+
213
+ # Extension metrics
214
+ self._metrics_lock = RLock()
215
+ self._metrics: List[str] = []
216
+
217
+ # Self monitoring metrics
218
+ self._sfm_metrics_lock = Lock()
219
+ self._callbackSfmReport: Dict[str, WrappedCallback] = {}
220
+
221
+ # Count metric delta signals
222
+ self._delta_signal_buffer: set[str] = set()
223
+ self._registered_count_metrics: set[str] = set()
224
+
225
+ # Self tech rule
226
+ self._techrule = ""
227
+
228
+ # Error message from caught exception in self.initialize()
229
+ self._initialization_error: str = ""
230
+
231
+ self._parse_args()
232
+
233
+ for function, interval, args, activation_type in Extension.schedule_decorators:
234
+ params = (self,)
235
+ if args is not None:
236
+ params = params + args
237
+ self.schedule(function, interval, params, activation_type)
238
+
239
+ api_logger.info("-----------------------------------------------------")
240
+ api_logger.info(f"Starting {self.__class__} {self.extension_name}, version: {self.get_version()}")
241
+
242
+ @property
243
+ def is_helper(self) -> bool:
244
+ """Internal property used by the EEC."""
245
+
246
+ return False
247
+
248
+ @property
249
+ def task_id(self) -> str:
250
+ """Internal property used by the EEC."""
251
+
252
+ return self._task_id
253
+
254
+ @property
255
+ def monitoring_config_id(self) -> str:
256
+ """Internal property used by the EEC.
257
+
258
+ Represents a unique identifier of the monitoring configuration.
259
+ that is assigned to this particular extension instance.
260
+ """
261
+
262
+ return self._monitoring_config_id
263
+
264
+ def run(self):
265
+ """Launch the extension instance.
266
+
267
+ Calling this method starts the main loop of the extension.
268
+
269
+ This method must be invoked once to start the extension,
270
+
271
+ if `--fastcheck` is set, the extension will run in fastcheck mode,
272
+ otherwise the main loop is started, which periodically runs:
273
+
274
+ * The scheduled callbacks
275
+ * The heartbeat method
276
+ * The metrics publisher method
277
+ """
278
+
279
+ self._setup_signal_handlers()
280
+ if self._is_fastcheck:
281
+ return self._run_fastcheck()
282
+ self._start_extension_loop()
283
+
284
+ def _setup_signal_handlers(self):
285
+ if sys.platform == "win32":
286
+ signal.signal(signal.SIGBREAK, self._shutdown_signal_handler)
287
+ signal.signal(signal.SIGINT, self._shutdown_signal_handler)
288
+
289
+ def _shutdown_signal_handler(self, sig, frame): # noqa: ARG002
290
+ api_logger.info(f"{signal.Signals(sig).name} captured. Flushing metrics and exiting...")
291
+ self.on_shutdown()
292
+ self._send_metrics()
293
+ self._send_sfm_metrics()
294
+ sys.exit(0)
295
+
296
+ def on_shutdown(self):
297
+ """Callback method to be invoked when the extension is shutting down.
298
+
299
+ Called when extension exits after it has received shutdown signal from EEC
300
+ This is executed before metrics are flushed to EEC
301
+ """
302
+ pass
303
+
304
+ def _schedule_callback(self, callback: WrappedCallback):
305
+ if callback.activation_type is not None and callback.activation_type != self.activation_config.type:
306
+ api_logger.info(
307
+ f"Skipping {callback} with activation type {callback.activation_type} because it is not {self.activation_config.type}"
308
+ )
309
+ return
310
+
311
+ api_logger.debug(f"Scheduling callback {callback}")
312
+
313
+ # These properties are updated after the extension starts
314
+ callback.cluster_time_diff = self._cluster_time_diff
315
+ callback.running_in_sim = self._running_in_sim
316
+ self._scheduled_callbacks.append(callback)
317
+ self._scheduler.enter(callback.initial_wait_time(), 1, self._callback_iteration, (callback,))
318
+
319
+ def schedule(
320
+ self,
321
+ callback: Callable,
322
+ interval: Union[timedelta, int],
323
+ args: Optional[tuple] = None,
324
+ activation_type: Optional[ActivationType] = None,
325
+ ) -> None:
326
+ """Schedule a method to be executed periodically.
327
+
328
+ The callback method will be periodically invoked in a separate thread.
329
+ The callback method is always immediately scheduled for execution.
330
+
331
+ Args:
332
+ callback: The callback method to be invoked
333
+ interval: The time interval between invocations, can be a timedelta object,
334
+ or an int representing the number of seconds
335
+ args: Arguments to the callback, if any
336
+ activation_type: Optional activation type when this callback should run,
337
+ can be 'ActivationType.LOCAL' or 'ActivationType.REMOTE'
338
+ """
339
+
340
+ if isinstance(interval, int):
341
+ interval = timedelta(seconds=interval)
342
+
343
+ callback = WrappedCallback(interval, callback, api_logger, args, activation_type=activation_type)
344
+ if self._is_fastcheck:
345
+ self._scheduled_callbacks_before_run.append(callback)
346
+ else:
347
+ self._schedule_callback(callback)
348
+
349
+ def query(self):
350
+ """Callback to be executed every minute by default.
351
+
352
+ Optional method that can be implemented by subclasses.
353
+ The query method is always scheduled to run every minute.
354
+ """
355
+ pass
356
+
357
+ def initialize(self):
358
+ """Callback to be executed when the extension starts.
359
+
360
+ Called once after the extension starts and the processes arguments are parsed.
361
+ Sometimes there are tasks the user needs to do that must happen before runtime,
362
+ but after the activation config has been received, example: Setting the schedule frequency
363
+ based on the user input on the monitoring configuration, this can be done on this method
364
+ """
365
+ pass
366
+
367
+ def fastcheck(self) -> Status:
368
+ """Callback executed when extension is launched.
369
+
370
+ Called if the extension is run in the `fastcheck` mode. Only invoked for remote
371
+ extensions.
372
+ This method is not called if fastcheck callback was already registered with
373
+ Extension.register_fastcheck().
374
+
375
+ Returns:
376
+ Status with optional message whether the fastcheck succeed or failed.
377
+ """
378
+ return Status(StatusValue.OK)
379
+
380
+ def register_fastcheck(self, fast_check_callback: Callable[[ActivationConfig, str], Status]):
381
+ """Registers fastcheck callback that is executed in the `fastcheck` mode.
382
+
383
+ Extension.fastcheck() is not called if fastcheck callback is registered with this method
384
+
385
+ Args:
386
+ fast_check_callback: callable called with ActivationConfig and
387
+ extension_config arguments. Must return the Status with optional message
388
+ whether the fastcheck succeed or failed.
389
+ """
390
+ if self._fast_check_callback:
391
+ api_logger.error("More than one function assigned to fastcheck, last registered one was kept.")
392
+
393
+ self._fast_check_callback = fast_check_callback
394
+
395
+ def _register_count_metrics(self, *count_metric_entries: CountMetricRegistrationEntry) -> None:
396
+ """Send a count metric registration request to EEC.
397
+
398
+ Args:
399
+ count_metric_entries: CountMetricRegistrationEntry objects for each count metric to register
400
+ """
401
+ json_pattern = {
402
+ metric_entry.metric_key: metric_entry.registration_items_dict() for metric_entry in count_metric_entries
403
+ }
404
+ self._client.register_count_metrics(json_pattern)
405
+
406
+ def _send_count_delta_signal(self, metric_keys: set[str], force: bool = True) -> None:
407
+ """Send calculate-delta signal to EEC monotonic converter.
408
+
409
+ Args:
410
+ metric_keys: List with metrics for which we want to calculate deltas
411
+ force: If true, it forces the metrics from cache to be pushed into EEC and then delta signal request is
412
+ sent. Otherwise, it puts delta signal request in cache and request is sent after nearest (in time) sending
413
+ metrics to EEC event
414
+ """
415
+
416
+ with self._metrics_lock:
417
+ if not force:
418
+ for key in metric_keys:
419
+ self._delta_signal_buffer.add(key)
420
+ return
421
+
422
+ self._send_metrics()
423
+ self._client.send_count_delta_signal(metric_keys)
424
+ self._delta_signal_buffer = {
425
+ metric_key for metric_key in self._delta_signal_buffer if metric_key not in metric_keys
426
+ }
427
+
428
+ def report_metric(
429
+ self,
430
+ key: str,
431
+ value: Union[float, str, int, SummaryStat],
432
+ dimensions: Optional[Dict[str, str]] = None,
433
+ techrule: Optional[str] = None,
434
+ timestamp: Optional[datetime] = None,
435
+ metric_type: MetricType = MetricType.GAUGE,
436
+ ) -> None:
437
+ """Report a metric.
438
+
439
+ Metric is sent to EEC using an HTTP request and MINT protocol. EEC then
440
+ sends the metrics to the tenant.
441
+
442
+ By default, it reports a gauge metric.
443
+
444
+ Args:
445
+ key: The metric key, must follow the MINT specification
446
+ value: The metric value, can be a simple value or a SummaryStat
447
+ dimensions: A dictionary of dimensions
448
+ techrule: The technology rule string set by self.techrule setter.
449
+ timestamp: The timestamp of the metric, defaults to the current time
450
+ metric_type: The type of the metric, defaults to MetricType.GAUGE
451
+ """
452
+
453
+ if techrule:
454
+ if not dimensions:
455
+ dimensions = {}
456
+ if "dt.techrule.id" not in dimensions:
457
+ dimensions["dt.techrule.id"] = techrule
458
+
459
+ if metric_type == MetricType.COUNT and timestamp is None:
460
+ # We must report a timestamp for count metrics
461
+ timestamp = datetime.now()
462
+
463
+ metric = Metric(key=key, value=value, dimensions=dimensions, metric_type=metric_type, timestamp=timestamp)
464
+ self._add_metric(metric)
465
+
466
+ def report_mint_lines(self, lines: List[str]) -> None:
467
+ """Report mint lines using the MINT protocol
468
+
469
+ Examples:
470
+ Metric lines must comply with the MINT format.
471
+
472
+ >>> self.report_mint_lines(["my_metric 1", "my_other_metric 2"])
473
+
474
+ Args:
475
+ lines: A list of mint lines
476
+ """
477
+ self._add_mint_lines(lines)
478
+
479
+ def report_event(
480
+ self,
481
+ title: str,
482
+ description: str,
483
+ properties: Optional[dict] = None,
484
+ timestamp: Optional[datetime] = None,
485
+ severity: Union[Severity, str] = Severity.INFO,
486
+ ) -> None:
487
+ """Report an event using log ingest.
488
+
489
+ Args:
490
+ title: The title of the event
491
+ description: The description of the event
492
+ properties: A dictionary of extra event properties
493
+ timestamp: The timestamp of the event, defaults to the current time
494
+ severity: The severity of the event, defaults to Severity.INFO
495
+ """
496
+ if timestamp is None:
497
+ timestamp = datetime.now(tz=timezone.utc)
498
+
499
+ if properties is None:
500
+ properties = {}
501
+
502
+ event = {
503
+ "content": f"{title}\n{description}",
504
+ "title": title,
505
+ "description": description,
506
+ "timestamp": timestamp.strftime(RFC_3339_FORMAT),
507
+ "severity": severity.value if isinstance(severity, Severity) else severity,
508
+ **self._metadata,
509
+ **properties,
510
+ }
511
+ self._send_events(event)
512
+
513
+ def report_dt_event(
514
+ self,
515
+ event_type: DtEventType,
516
+ title: str,
517
+ start_time: Optional[int] = None,
518
+ end_time: Optional[int] = None,
519
+ timeout: Optional[int] = None,
520
+ entity_selector: Optional[str] = None,
521
+ properties: Optional[dict[str, str]] = None,
522
+ ) -> None:
523
+ """
524
+ Reports an event using the v2 event ingest API.
525
+
526
+ Unlike ``report_event``, this directly raises an event or even a problem
527
+ based on the specified ``event_type``.
528
+
529
+ Note:
530
+ For reference see: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/events-v2/post-event
531
+
532
+ Args:
533
+ event_type: The event type chosen from type Enum (required)
534
+ title: The title of the event (required)
535
+ start_time: The start time of event in UTC ms, if not set, current timestamp (optional)
536
+ end_time: The end time of event in UTC ms, if not set, current timestamp + timeout (optional)
537
+ timeout: The timeout of event in minutes, if not set, 15 (optional)
538
+ entity_selector: The entity selector, if not set, the event is associated with environment entity (optional)
539
+ properties: A map of event properties (optional)
540
+ """
541
+ event: Dict[str, Any] = {"eventType": event_type, "title": title}
542
+ if start_time:
543
+ event["startTime"] = start_time
544
+ if end_time:
545
+ event["endTime"] = end_time
546
+ if timeout:
547
+ event["timeout"] = timeout
548
+ if entity_selector:
549
+ event["entitySelector"] = entity_selector
550
+ if properties:
551
+ event["properties"] = properties
552
+
553
+ self._send_dt_event(event)
554
+
555
+ def report_dt_event_dict(self, event: dict):
556
+ """Report an event using event ingest API with provided dictionary.
557
+
558
+ Note:
559
+ For reference see: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/events-v2/post-event
560
+
561
+ Format of the event dictionary::
562
+
563
+ {
564
+ "type": "object",
565
+ "required": ["eventType", "title"],
566
+ "properties": {
567
+ "eventType": {
568
+ "type": "string",
569
+ "enum": [
570
+ "CUSTOM_INFO",
571
+ "CUSTOM_ANNOTATION",
572
+ "CUSTOM_CONFIGURATION",
573
+ "CUSTOM_DEPLOYMENT",
574
+ "MARKED_FOR_TERMINATION",
575
+ "ERROR_EVENT",
576
+ "AVAILABILITY_EVENT",
577
+ "PERFORMANCE_EVENT",
578
+ "RESOURCE_CONTENTION_EVENT",
579
+ "CUSTOM_ALERT"
580
+ ]
581
+ },
582
+ "title": {
583
+ "type": "string",
584
+ "minLength": 1
585
+ },
586
+ "startTime": {"type": "integer"},
587
+ "endTime": {"type": "integer"},
588
+ "timeout": {"type": "integer"},
589
+ "entitySelector": {"type": "string"},
590
+ "properties": {
591
+ "type": "object",
592
+ "patternProperties": {
593
+ "^.*$": {"type": "string"}
594
+ }
595
+ }
596
+ }
597
+ }
598
+ """
599
+
600
+ if "eventType" not in event or "title" not in event:
601
+ raise ValueError('"eventType" not present' if "eventType" not in event else '"title" not present in event')
602
+ for key, value in event.items():
603
+ if DT_EVENT_SCHEMA[key] is None:
604
+ msg = f'invalid member: "{key}"'
605
+ raise ValueError(msg)
606
+ if key == "eventType" and value not in list(DtEventType):
607
+ msg = f"Event type must be a DtEventType enum value, got: {value}"
608
+ raise ValueError(msg)
609
+ if key == "properties":
610
+ for prop_key, prop_val in event[key].items():
611
+ if not isinstance(prop_key, str) or not isinstance(prop_val, str):
612
+ msg = f'invalid "properties" member: {prop_key}: {prop_val}, required: "str": str'
613
+ raise ValueError(msg)
614
+ self._send_dt_event(event)
615
+
616
+ def report_log_event(self, log_event: dict):
617
+ """Report a custom log event using log ingest.
618
+
619
+ Note:
620
+ See reference: https://www.dynatrace.com/support/help/shortlink/log-monitoring-log-data-ingestion
621
+
622
+ Args:
623
+ log_event: The log event dictionary.
624
+ """
625
+ self._send_events(log_event)
626
+
627
+ def report_log_events(self, log_events: List[dict]):
628
+ """Report a list of custom log events using log ingest.
629
+
630
+ Args:
631
+ log_events: The list of log events
632
+ """
633
+ self._send_events(log_events)
634
+
635
+ def report_log_lines(self, log_lines: List[Union[str, bytes]]):
636
+ """Report a list of log lines using log ingest
637
+
638
+ Args:
639
+ log_lines: The list of log lines
640
+ """
641
+ events = [{"content": line} for line in log_lines]
642
+ self._send_events(events)
643
+
644
+ @property
645
+ def enabled_feature_sets(self) -> dict[str, list[str]]:
646
+ """Map of enabled feautre sets and corresponding metrics.
647
+
648
+ Returns:
649
+ Dictionary containing enabled feature sets with corresponding
650
+ metrics defined in ``extension.yaml``.
651
+ """
652
+ return {
653
+ feature_set_name: metric_keys
654
+ for feature_set_name, metric_keys in self._feature_sets.items()
655
+ if feature_set_name in self.activation_config.feature_sets or feature_set_name == "default"
656
+ }
657
+
658
+ @property
659
+ def enabled_feature_sets_names(self) -> list[str]:
660
+ """Names of enabled feature sets.
661
+
662
+ Returns:
663
+ List containing names of enabled feature sets.
664
+ """
665
+ return list(self.enabled_feature_sets.keys())
666
+
667
+ @property
668
+ def enabled_feature_sets_metrics(self) -> list[str]:
669
+ """Enabled metrics.
670
+
671
+ Returns:
672
+ List of all metric keys from enabled feature sets
673
+ """
674
+ return list(chain(*self.enabled_feature_sets.values()))
675
+
676
+ def _parse_args(self):
677
+ parser = ArgumentParser(description="Python extension parameters")
678
+
679
+ # Production parameters, these are passed by the EEC
680
+ parser.add_argument("--dsid", required=False, default=None)
681
+ parser.add_argument("--url", required=False)
682
+ parser.add_argument("--idtoken", required=False)
683
+ parser.add_argument(
684
+ "--loglevel",
685
+ help="Set extension log level. Info is default.",
686
+ type=str,
687
+ choices=["debug", "info"],
688
+ default="info",
689
+ )
690
+ parser.add_argument("--fastcheck", action="store_true", default=False)
691
+ parser.add_argument("--monitoring_config_id", required=False, default=None)
692
+ parser.add_argument("--local-ingest", action="store_true", default=False)
693
+ parser.add_argument("--local-ingest-port", required=False, default=14499)
694
+
695
+ # Debug parameters, these are used when running the extension locally
696
+ parser.add_argument("--extensionconfig", required=False, default=None)
697
+ parser.add_argument("--activationconfig", required=False, default="activation.json")
698
+ parser.add_argument("--no-print-metrics", required=False, action="store_true")
699
+
700
+ args, unknown = parser.parse_known_args()
701
+ self._is_fastcheck = args.fastcheck
702
+ if args.dsid is None:
703
+ # DEV mode
704
+ self._running_in_sim = True
705
+ print_metrics = not args.no_print_metrics
706
+ self._client = DebugClient(
707
+ activation_config_path=args.activationconfig,
708
+ extension_config_path=args.extensionconfig,
709
+ logger=api_logger,
710
+ local_ingest=args.local_ingest,
711
+ local_ingest_port=args.local_ingest_port,
712
+ print_metrics=print_metrics
713
+ )
714
+ RuntimeProperties.set_default_log_level(args.loglevel)
715
+ else:
716
+ # EEC mode
717
+ self._client = HttpClient(args.url, args.dsid, args.idtoken, api_logger)
718
+ self._task_id = args.dsid
719
+ self._monitoring_config_id = args.monitoring_config_id
720
+ api_logger.info(f"DSID = {self.task_id}, monitoring config id = {self._monitoring_config_id}")
721
+
722
+ self.activation_config = ActivationConfig(self._client.get_activation_config())
723
+ self.extension_config = self._client.get_extension_config()
724
+ self._feature_sets = self._client.get_feature_sets()
725
+
726
+ self.monitoring_config_name = self.activation_config.description
727
+ self.extension_version = self.activation_config.version
728
+
729
+ if not self._is_fastcheck:
730
+ try:
731
+ self.initialize()
732
+ if not self.is_helper:
733
+ self.schedule(self.query, timedelta(minutes=1))
734
+ except Exception as e:
735
+ msg = f"Error running self.initialize {self}: {e!r}"
736
+ api_logger.exception(msg)
737
+ self._client.send_status(Status(StatusValue.GENERIC_ERROR, msg))
738
+ self._initialization_error = msg
739
+ raise e
740
+
741
+ @property
742
+ def _metadata(self) -> dict:
743
+ return {
744
+ "dt.extension.config.id": self._runtime_properties.extconfig,
745
+ "dt.extension.ds": DATASOURCE_TYPE,
746
+ "dt.extension.version": self.extension_version,
747
+ "dt.extension.name": self.extension_name,
748
+ "monitoring.configuration": self.monitoring_config_name,
749
+ }
750
+
751
+ def _run_fastcheck(self):
752
+ api_logger.info(f"Running fastcheck for monitoring configuration '{self.monitoring_config_name}'")
753
+ try:
754
+ if self._fast_check_callback:
755
+ status = self._fast_check_callback(self.activation_config, self.extension_config)
756
+ api_logger.info(f"Sending fastcheck status: {status}")
757
+ self._client.send_status(status)
758
+ return
759
+
760
+ status = self.fastcheck()
761
+ api_logger.info(f"Sending fastcheck status: {status}")
762
+ self._client.send_status(status)
763
+ except Exception as e:
764
+ status = Status(StatusValue.GENERIC_ERROR, f"Python datasource fastcheck error: {e!r}")
765
+ api_logger.error(f"Error running fastcheck {self}: {e!r}")
766
+ self._client.send_status(status)
767
+ raise
768
+
769
+ def _run_callback(self, callback: WrappedCallback):
770
+ if not callback.running:
771
+ # Add the callback to the list of running callbacks
772
+ with self._running_callbacks_lock:
773
+ current_thread_id = threading.get_ident()
774
+ self._running_callbacks[current_thread_id] = callback
775
+
776
+ callback(self.activation_config, self.extension_config)
777
+
778
+ with self._sfm_metrics_lock:
779
+ self._callbackSfmReport[callback.name()] = callback
780
+ # Remove the callback from the list of running callbacks
781
+ with self._running_callbacks_lock:
782
+ self._running_callbacks.pop(current_thread_id, None)
783
+
784
+ def _callback_iteration(self, callback: WrappedCallback):
785
+ self._callbacks_executor.submit(self._run_callback, callback)
786
+ self._scheduler.enter(callback.interval.total_seconds(), 1, self._callback_iteration, (callback,))
787
+
788
+ def _start_extension_loop(self):
789
+ api_logger.debug(f"Starting main loop for monitoring configuration: '{self.monitoring_config_name}'")
790
+
791
+ # These were scheduled before the extension started, schedule them now
792
+ for callback in self._scheduled_callbacks_before_run:
793
+ self._schedule_callback(callback)
794
+ self._heartbeat_iteration()
795
+ self._metrics_iteration()
796
+ self._sfm_metrics_iteration()
797
+ self._timediff_iteration()
798
+ self._scheduler.run()
799
+
800
+ def _timediff_iteration(self):
801
+ self._internal_executor.submit(self._update_cluster_time_diff)
802
+ self._scheduler.enter(TIME_DIFF_INTERVAL.total_seconds(), 1, self._timediff_iteration)
803
+
804
+ def _heartbeat_iteration(self):
805
+ self._internal_executor.submit(self._heartbeat)
806
+ self._scheduler.enter(HEARTBEAT_INTERVAL.total_seconds(), 1, self._heartbeat_iteration)
807
+
808
+ def _metrics_iteration(self):
809
+ self._internal_executor.submit(self._send_metrics)
810
+ self._scheduler.enter(METRIC_SENDING_INTERVAL.total_seconds(), 1, self._metrics_iteration)
811
+
812
+ def _sfm_metrics_iteration(self):
813
+ self._internal_executor.submit(self._send_sfm_metrics)
814
+ self._scheduler.enter(SFM_METRIC_SENDING_INTERVAL.total_seconds(), 1, self._sfm_metrics_iteration)
815
+
816
+ def _send_metrics(self):
817
+ with self._metrics_lock:
818
+ with self._internal_callbacks_results_lock:
819
+ if self._metrics:
820
+ number_of_metrics = len(self._metrics)
821
+ responses = self._client.send_metrics(self._metrics)
822
+
823
+ self._internal_callbacks_results[self._send_metrics.__name__] = Status(StatusValue.OK)
824
+ lines_invalid = sum(response.lines_invalid for response in responses)
825
+ if lines_invalid > 0:
826
+ message = f"{lines_invalid} invalid metric lines found"
827
+ self._internal_callbacks_results[self._send_metrics.__name__] = Status(
828
+ StatusValue.GENERIC_ERROR, message
829
+ )
830
+
831
+ api_logger.info(f"Sent {number_of_metrics} metric lines to EEC: {responses}")
832
+ self._metrics = []
833
+
834
+ def _prepare_sfm_metrics(self) -> List[str]:
835
+ """Prepare self monitoring metrics.
836
+
837
+ Builds the list of mint metric lines to send as self monitoring metrics.
838
+ """
839
+
840
+ sfm_metrics: List[Metric] = []
841
+ sfm_dimensions = {"dt.extension.config.id": self.monitoring_config_id}
842
+ _add_sfm_metric(
843
+ SfmMetric("threads", active_count(), sfm_dimensions, client_facing=True, metric_type=MetricType.DELTA),
844
+ sfm_metrics,
845
+ )
846
+
847
+ for name, callback in self._callbackSfmReport.items():
848
+ sfm_dimensions = {"callback": name, "dt.extension.config.id": self.monitoring_config_id}
849
+ _add_sfm_metric(
850
+ SfmMetric(
851
+ "execution.time",
852
+ f"{callback.duration_interval_total:.4f}",
853
+ sfm_dimensions,
854
+ client_facing=True,
855
+ metric_type=MetricType.GAUGE,
856
+ ),
857
+ sfm_metrics,
858
+ )
859
+ _add_sfm_metric(
860
+ SfmMetric(
861
+ "execution.total.count",
862
+ callback.executions_total,
863
+ sfm_dimensions,
864
+ client_facing=True,
865
+ metric_type=MetricType.DELTA,
866
+ ),
867
+ sfm_metrics,
868
+ )
869
+ _add_sfm_metric(
870
+ SfmMetric(
871
+ "execution.count",
872
+ callback.executions_per_interval,
873
+ sfm_dimensions,
874
+ client_facing=True,
875
+ metric_type=MetricType.DELTA,
876
+ ),
877
+ sfm_metrics,
878
+ )
879
+ _add_sfm_metric(
880
+ SfmMetric(
881
+ "execution.ok.count",
882
+ callback.ok_count,
883
+ sfm_dimensions,
884
+ client_facing=True,
885
+ metric_type=MetricType.DELTA,
886
+ ),
887
+ sfm_metrics,
888
+ )
889
+ _add_sfm_metric(
890
+ SfmMetric(
891
+ "execution.timeout.count",
892
+ callback.timeouts_count,
893
+ sfm_dimensions,
894
+ client_facing=True,
895
+ metric_type=MetricType.DELTA,
896
+ ),
897
+ sfm_metrics,
898
+ )
899
+ _add_sfm_metric(
900
+ SfmMetric(
901
+ "execution.exception.count",
902
+ callback.exception_count,
903
+ sfm_dimensions,
904
+ client_facing=True,
905
+ metric_type=MetricType.DELTA,
906
+ ),
907
+ sfm_metrics,
908
+ )
909
+ callback.clear_sfm_metrics()
910
+ return [metric.to_mint_line() for metric in sfm_metrics]
911
+
912
+ def _send_sfm_metrics(self):
913
+ with self._sfm_metrics_lock:
914
+ lines = self._prepare_sfm_metrics()
915
+ # Flushes the cache of metrics, maybe we should only flush if they were successfully sent
916
+ self._callbackSfmReport.clear()
917
+ response = self._client.send_sfm_metrics(lines)
918
+
919
+ with self._internal_callbacks_results_lock:
920
+ self._internal_callbacks_results[self._send_sfm_metrics.__name__] = Status(StatusValue.OK)
921
+ if response.lines_invalid > 0:
922
+ message = f"{response.lines_invalid} invalid metric lines found"
923
+ self._internal_callbacks_results[self._send_sfm_metrics.__name__] = Status(
924
+ StatusValue.GENERIC_ERROR, message
925
+ )
926
+
927
+ def _build_current_status(self):
928
+ overall_status = Status(StatusValue.OK)
929
+
930
+ if self._initialization_error:
931
+ overall_status.status = StatusValue.GENERIC_ERROR
932
+ overall_status.message = self._initialization_error
933
+ return overall_status
934
+
935
+ internal_callback_error = False
936
+ messages = []
937
+ with self._internal_callbacks_results_lock:
938
+ for callback, result in self._internal_callbacks_results.items():
939
+ if result.is_error():
940
+ internal_callback_error = True
941
+ overall_status.status = result.status
942
+ messages.append(f"{callback}: {result.message}")
943
+ if internal_callback_error:
944
+ overall_status.message = "\n".join(messages)
945
+ return overall_status
946
+
947
+ for callback in self._scheduled_callbacks:
948
+ overall_status.timestamp = int(callback.get_adjusted_metric_timestamp().timestamp() * 1000)
949
+ if callback.status.is_error():
950
+ overall_status.status = callback.status.status
951
+ messages.append(f"{callback}: {callback.status.message}")
952
+
953
+ overall_status.message = "\n".join(messages)
954
+ return overall_status
955
+
956
+ def _update_cluster_time_diff(self):
957
+ self._cluster_time_diff = self._client.get_cluster_time_diff()
958
+ for callback in self._scheduled_callbacks:
959
+ callback.cluster_time_diff = self._cluster_time_diff
960
+
961
+ def _heartbeat(self):
962
+ response = bytes("not set", "utf-8")
963
+ try:
964
+ overall_status = self._build_current_status()
965
+ response = self._client.send_status(overall_status)
966
+ self._runtime_properties = RuntimeProperties(response)
967
+ except Exception as e:
968
+ api_logger.error(f"Heartbeat failed because {e}, response {response}", exc_info=True)
969
+
970
+ def __del__(self):
971
+ self._callbacks_executor.shutdown()
972
+ self._internal_executor.shutdown()
973
+
974
+ def _add_metric(self, metric: Metric):
975
+ metric.validate()
976
+
977
+ with self._running_callbacks_lock:
978
+ current_thread_id = threading.get_ident()
979
+ current_callback = self._running_callbacks.get(current_thread_id)
980
+
981
+ if current_callback is not None and metric.timestamp is None:
982
+ # Adjust the metric timestamp according to the callback start time
983
+ # If the user manually set a metric timestamp, don't adjust it
984
+ metric.timestamp = current_callback.get_adjusted_metric_timestamp()
985
+ elif current_callback is None and metric.timestamp is None:
986
+ api_logger.debug(
987
+ f"Metric {metric} was added by unknown thread {current_thread_id}, cannot adjust the timestamp"
988
+ )
989
+
990
+ with self._metrics_lock:
991
+ self._metrics.append(metric.to_mint_line())
992
+
993
+ def _add_mint_lines(self, lines: List[str]):
994
+ with self._metrics_lock:
995
+ self._metrics.extend(lines)
996
+
997
+ def _send_events_internal(self, events: Union[dict, List[dict]]):
998
+ response = self._client.send_events(events, self.log_event_enrichment)
999
+ with self._internal_callbacks_results_lock:
1000
+ self._internal_callbacks_results[self._send_events.__name__] = Status(StatusValue.OK)
1001
+ if not response or "error" not in response or "message" not in response["error"]:
1002
+ return
1003
+ self._internal_callbacks_results[self._send_events.__name__] = Status(
1004
+ StatusValue.GENERIC_ERROR, response["error"]["message"]
1005
+ )
1006
+
1007
+ def _send_events(self, events: Union[dict, List[dict]]):
1008
+ self._internal_executor.submit(self._send_events_internal, events)
1009
+
1010
+ def _send_dt_event(self, event: dict[str, str | int | dict[str, str]]):
1011
+ self._client.send_dt_event(event)
1012
+
1013
+ def get_version(self) -> str:
1014
+ """Return the version of extensions sdk library."""
1015
+
1016
+ return __version__
1017
+
1018
+ @property
1019
+ def techrule(self) -> str:
1020
+ """Internal property used by the EEC."""
1021
+
1022
+ return self._techrule
1023
+
1024
+ @techrule.setter
1025
+ def techrule(self, value):
1026
+ self._techrule = value
1027
+
1028
+ def get_activation_config(self) -> ActivationConfig:
1029
+ """Retrieve the activation config.
1030
+
1031
+ Represents activation configuration assigned to this particular
1032
+ extension instance.
1033
+
1034
+ Returns:
1035
+ ActivationConfig object.
1036
+ """
1037
+ return self.activation_config