dt-extensions-sdk 1.1.23__py3-none-any.whl → 1.2.0__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.
- {dt_extensions_sdk-1.1.23.dist-info → dt_extensions_sdk-1.2.0.dist-info}/METADATA +2 -2
- dt_extensions_sdk-1.2.0.dist-info/RECORD +34 -0
- {dt_extensions_sdk-1.1.23.dist-info → dt_extensions_sdk-1.2.0.dist-info}/WHEEL +1 -1
- {dt_extensions_sdk-1.1.23.dist-info → dt_extensions_sdk-1.2.0.dist-info}/licenses/LICENSE.txt +9 -9
- dynatrace_extension/__about__.py +5 -5
- dynatrace_extension/__init__.py +27 -27
- dynatrace_extension/cli/__init__.py +5 -5
- dynatrace_extension/cli/create/__init__.py +1 -1
- dynatrace_extension/cli/create/create.py +76 -76
- dynatrace_extension/cli/create/extension_template/.gitignore.template +160 -160
- dynatrace_extension/cli/create/extension_template/README.md.template +33 -33
- dynatrace_extension/cli/create/extension_template/activation.json.template +15 -15
- dynatrace_extension/cli/create/extension_template/extension/activationSchema.json.template +118 -118
- dynatrace_extension/cli/create/extension_template/extension/extension.yaml.template +17 -17
- dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template +43 -43
- dynatrace_extension/cli/create/extension_template/setup.py.template +28 -28
- dynatrace_extension/cli/main.py +432 -428
- dynatrace_extension/cli/schema.py +129 -129
- dynatrace_extension/sdk/__init__.py +3 -3
- dynatrace_extension/sdk/activation.py +43 -43
- dynatrace_extension/sdk/callback.py +134 -134
- dynatrace_extension/sdk/communication.py +483 -482
- dynatrace_extension/sdk/event.py +19 -19
- dynatrace_extension/sdk/extension.py +1065 -1045
- dynatrace_extension/sdk/helper.py +191 -191
- dynatrace_extension/sdk/metric.py +118 -118
- dynatrace_extension/sdk/runtime.py +67 -67
- dynatrace_extension/sdk/snapshot.py +198 -0
- dynatrace_extension/sdk/vendor/mureq/LICENSE +13 -13
- dynatrace_extension/sdk/vendor/mureq/mureq.py +448 -447
- dt_extensions_sdk-1.1.23.dist-info/RECORD +0 -33
- {dt_extensions_sdk-1.1.23.dist-info → dt_extensions_sdk-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,129 +1,129 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import json
|
4
|
-
from pathlib import Path
|
5
|
-
|
6
|
-
import yaml
|
7
|
-
|
8
|
-
|
9
|
-
class ExtensionYaml:
|
10
|
-
def __init__(self, yaml_file: Path):
|
11
|
-
self._file = yaml_file
|
12
|
-
self._data = yaml.safe_load(yaml_file.read_text())
|
13
|
-
|
14
|
-
@property
|
15
|
-
def name(self) -> str:
|
16
|
-
return self._data.get("name", "")
|
17
|
-
|
18
|
-
@property
|
19
|
-
def version(self) -> str:
|
20
|
-
return self._data.get("version", "")
|
21
|
-
|
22
|
-
@property
|
23
|
-
def min_dynatrace_version(self) -> str:
|
24
|
-
return self._data.get("minDynatraceVersion", "")
|
25
|
-
|
26
|
-
@property
|
27
|
-
def author(self) -> Author:
|
28
|
-
return Author(self._data.get("author", {}))
|
29
|
-
|
30
|
-
@property
|
31
|
-
def python(self) -> Python:
|
32
|
-
return Python(self._data.get("python", {}))
|
33
|
-
|
34
|
-
def validate(self):
|
35
|
-
"""
|
36
|
-
Checks that the files under 'python.activation' exist and are valid json files
|
37
|
-
"""
|
38
|
-
if self.python.activation.remote and self.python.activation.remote.path:
|
39
|
-
self._validate_json_file(self.python.activation.remote.path)
|
40
|
-
|
41
|
-
if self.python.activation.local and self.python.activation.local.path:
|
42
|
-
self._validate_json_file(self.python.activation.local.path)
|
43
|
-
|
44
|
-
def _validate_json_file(self, raw_path: str):
|
45
|
-
path = Path(Path(self._file).parent / raw_path)
|
46
|
-
if not path.exists():
|
47
|
-
msg = f"Extension yaml validation failed, file {path} does not exist"
|
48
|
-
raise ValueError(msg)
|
49
|
-
|
50
|
-
# Parse the file to make sure it is valid json
|
51
|
-
with path.open() as f:
|
52
|
-
json.load(f)
|
53
|
-
|
54
|
-
def zip_file_name(self) -> str:
|
55
|
-
return f"{self.name.replace(':', '_')}-{self.version}.zip"
|
56
|
-
|
57
|
-
|
58
|
-
class Python:
|
59
|
-
def __init__(self, data: dict):
|
60
|
-
self._data = data
|
61
|
-
|
62
|
-
@property
|
63
|
-
def runtime(self) -> Runtime:
|
64
|
-
return Runtime(self._data.get("runtime", {}))
|
65
|
-
|
66
|
-
@property
|
67
|
-
def activation(self):
|
68
|
-
return Activation(self._data.get("activation", {}))
|
69
|
-
|
70
|
-
|
71
|
-
class Runtime:
|
72
|
-
def __init__(self, data: dict):
|
73
|
-
self._data = data
|
74
|
-
|
75
|
-
@property
|
76
|
-
def module(self) -> str:
|
77
|
-
return self._data.get("module", "datasourcepy")
|
78
|
-
|
79
|
-
@property
|
80
|
-
def version(self) -> Version:
|
81
|
-
return Version(self._data.get("version", {}))
|
82
|
-
|
83
|
-
|
84
|
-
class Version:
|
85
|
-
def __init__(self, data: dict):
|
86
|
-
self._data = data
|
87
|
-
|
88
|
-
@property
|
89
|
-
def min_version(self) -> str:
|
90
|
-
return self._data.get("min", "")
|
91
|
-
|
92
|
-
@property
|
93
|
-
def max_version(self) -> str:
|
94
|
-
return self._data.get("max", "")
|
95
|
-
|
96
|
-
|
97
|
-
class Activation:
|
98
|
-
def __init__(self, data: dict):
|
99
|
-
self._data = data
|
100
|
-
|
101
|
-
@property
|
102
|
-
def remote(self) -> ActivationInstance | None:
|
103
|
-
if data := self._data.get("remote"):
|
104
|
-
return ActivationInstance(data)
|
105
|
-
return None
|
106
|
-
|
107
|
-
@property
|
108
|
-
def local(self) -> ActivationInstance | None:
|
109
|
-
if data := self._data.get("local"):
|
110
|
-
return ActivationInstance(data)
|
111
|
-
return None
|
112
|
-
|
113
|
-
|
114
|
-
class ActivationInstance:
|
115
|
-
def __init__(self, data: dict):
|
116
|
-
self._data = data
|
117
|
-
|
118
|
-
@property
|
119
|
-
def path(self) -> str:
|
120
|
-
return self._data.get("path", "")
|
121
|
-
|
122
|
-
|
123
|
-
class Author:
|
124
|
-
def __init__(self, _data: dict):
|
125
|
-
self._data = _data
|
126
|
-
|
127
|
-
@property
|
128
|
-
def name(self) -> str:
|
129
|
-
return self._data.get("name", "")
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
import yaml
|
7
|
+
|
8
|
+
|
9
|
+
class ExtensionYaml:
|
10
|
+
def __init__(self, yaml_file: Path):
|
11
|
+
self._file = yaml_file
|
12
|
+
self._data = yaml.safe_load(yaml_file.read_text())
|
13
|
+
|
14
|
+
@property
|
15
|
+
def name(self) -> str:
|
16
|
+
return self._data.get("name", "")
|
17
|
+
|
18
|
+
@property
|
19
|
+
def version(self) -> str:
|
20
|
+
return self._data.get("version", "")
|
21
|
+
|
22
|
+
@property
|
23
|
+
def min_dynatrace_version(self) -> str:
|
24
|
+
return self._data.get("minDynatraceVersion", "")
|
25
|
+
|
26
|
+
@property
|
27
|
+
def author(self) -> Author:
|
28
|
+
return Author(self._data.get("author", {}))
|
29
|
+
|
30
|
+
@property
|
31
|
+
def python(self) -> Python:
|
32
|
+
return Python(self._data.get("python", {}))
|
33
|
+
|
34
|
+
def validate(self):
|
35
|
+
"""
|
36
|
+
Checks that the files under 'python.activation' exist and are valid json files
|
37
|
+
"""
|
38
|
+
if self.python.activation.remote and self.python.activation.remote.path:
|
39
|
+
self._validate_json_file(self.python.activation.remote.path)
|
40
|
+
|
41
|
+
if self.python.activation.local and self.python.activation.local.path:
|
42
|
+
self._validate_json_file(self.python.activation.local.path)
|
43
|
+
|
44
|
+
def _validate_json_file(self, raw_path: str):
|
45
|
+
path = Path(Path(self._file).parent / raw_path)
|
46
|
+
if not path.exists():
|
47
|
+
msg = f"Extension yaml validation failed, file {path} does not exist"
|
48
|
+
raise ValueError(msg)
|
49
|
+
|
50
|
+
# Parse the file to make sure it is valid json
|
51
|
+
with path.open() as f:
|
52
|
+
json.load(f)
|
53
|
+
|
54
|
+
def zip_file_name(self) -> str:
|
55
|
+
return f"{self.name.replace(':', '_')}-{self.version}.zip"
|
56
|
+
|
57
|
+
|
58
|
+
class Python:
|
59
|
+
def __init__(self, data: dict):
|
60
|
+
self._data = data
|
61
|
+
|
62
|
+
@property
|
63
|
+
def runtime(self) -> Runtime:
|
64
|
+
return Runtime(self._data.get("runtime", {}))
|
65
|
+
|
66
|
+
@property
|
67
|
+
def activation(self):
|
68
|
+
return Activation(self._data.get("activation", {}))
|
69
|
+
|
70
|
+
|
71
|
+
class Runtime:
|
72
|
+
def __init__(self, data: dict):
|
73
|
+
self._data = data
|
74
|
+
|
75
|
+
@property
|
76
|
+
def module(self) -> str:
|
77
|
+
return self._data.get("module", "datasourcepy")
|
78
|
+
|
79
|
+
@property
|
80
|
+
def version(self) -> Version:
|
81
|
+
return Version(self._data.get("version", {}))
|
82
|
+
|
83
|
+
|
84
|
+
class Version:
|
85
|
+
def __init__(self, data: dict):
|
86
|
+
self._data = data
|
87
|
+
|
88
|
+
@property
|
89
|
+
def min_version(self) -> str:
|
90
|
+
return self._data.get("min", "")
|
91
|
+
|
92
|
+
@property
|
93
|
+
def max_version(self) -> str:
|
94
|
+
return self._data.get("max", "")
|
95
|
+
|
96
|
+
|
97
|
+
class Activation:
|
98
|
+
def __init__(self, data: dict):
|
99
|
+
self._data = data
|
100
|
+
|
101
|
+
@property
|
102
|
+
def remote(self) -> ActivationInstance | None:
|
103
|
+
if data := self._data.get("remote"):
|
104
|
+
return ActivationInstance(data)
|
105
|
+
return None
|
106
|
+
|
107
|
+
@property
|
108
|
+
def local(self) -> ActivationInstance | None:
|
109
|
+
if data := self._data.get("local"):
|
110
|
+
return ActivationInstance(data)
|
111
|
+
return None
|
112
|
+
|
113
|
+
|
114
|
+
class ActivationInstance:
|
115
|
+
def __init__(self, data: dict):
|
116
|
+
self._data = data
|
117
|
+
|
118
|
+
@property
|
119
|
+
def path(self) -> str:
|
120
|
+
return self._data.get("path", "")
|
121
|
+
|
122
|
+
|
123
|
+
class Author:
|
124
|
+
def __init__(self, _data: dict):
|
125
|
+
self._data = _data
|
126
|
+
|
127
|
+
@property
|
128
|
+
def name(self) -> str:
|
129
|
+
return self._data.get("name", "")
|
@@ -1,3 +1,3 @@
|
|
1
|
-
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
-
#
|
3
|
-
# SPDX-License-Identifier: MIT
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
@@ -1,43 +1,43 @@
|
|
1
|
-
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
-
#
|
3
|
-
# SPDX-License-Identifier: MIT
|
4
|
-
|
5
|
-
from enum import Enum
|
6
|
-
from typing import List
|
7
|
-
|
8
|
-
|
9
|
-
class ActivationType(Enum):
|
10
|
-
REMOTE = "remote"
|
11
|
-
LOCAL = "local"
|
12
|
-
|
13
|
-
|
14
|
-
class ActivationConfig(dict):
|
15
|
-
def __init__(self, activation_context_json: dict):
|
16
|
-
self._activation_context_json = activation_context_json
|
17
|
-
self.version: str = self._activation_context_json.get("version", "")
|
18
|
-
self.enabled: bool = self._activation_context_json.get("enabled", True)
|
19
|
-
self.description: str = self._activation_context_json.get("description", "")
|
20
|
-
self.feature_sets: List[str] = self._activation_context_json.get("featureSets", [])
|
21
|
-
self.type: ActivationType = ActivationType.REMOTE if self.remote else ActivationType.LOCAL
|
22
|
-
super().__init__()
|
23
|
-
|
24
|
-
@property
|
25
|
-
def config(self) -> dict:
|
26
|
-
return self.remote if self.remote else self.local
|
27
|
-
|
28
|
-
@property
|
29
|
-
def remote(self) -> dict:
|
30
|
-
return self._activation_context_json.get("pythonRemote", {})
|
31
|
-
|
32
|
-
@property
|
33
|
-
def local(self) -> dict:
|
34
|
-
return self._activation_context_json.get("pythonLocal", {})
|
35
|
-
|
36
|
-
def __getitem__(self, item):
|
37
|
-
return self.config[item]
|
38
|
-
|
39
|
-
def get(self, key, default=None):
|
40
|
-
return self.config.get(key, default)
|
41
|
-
|
42
|
-
def __repr__(self):
|
43
|
-
return f"ActivationConfig(version='{self.version}', enabled={self.enabled}, description='{self.description}', type={self.type}, config={self.config})"
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
|
5
|
+
from enum import Enum
|
6
|
+
from typing import List
|
7
|
+
|
8
|
+
|
9
|
+
class ActivationType(Enum):
|
10
|
+
REMOTE = "remote"
|
11
|
+
LOCAL = "local"
|
12
|
+
|
13
|
+
|
14
|
+
class ActivationConfig(dict):
|
15
|
+
def __init__(self, activation_context_json: dict):
|
16
|
+
self._activation_context_json = activation_context_json
|
17
|
+
self.version: str = self._activation_context_json.get("version", "")
|
18
|
+
self.enabled: bool = self._activation_context_json.get("enabled", True)
|
19
|
+
self.description: str = self._activation_context_json.get("description", "")
|
20
|
+
self.feature_sets: List[str] = self._activation_context_json.get("featureSets", [])
|
21
|
+
self.type: ActivationType = ActivationType.REMOTE if self.remote else ActivationType.LOCAL
|
22
|
+
super().__init__()
|
23
|
+
|
24
|
+
@property
|
25
|
+
def config(self) -> dict:
|
26
|
+
return self.remote if self.remote else self.local
|
27
|
+
|
28
|
+
@property
|
29
|
+
def remote(self) -> dict:
|
30
|
+
return self._activation_context_json.get("pythonRemote", {})
|
31
|
+
|
32
|
+
@property
|
33
|
+
def local(self) -> dict:
|
34
|
+
return self._activation_context_json.get("pythonLocal", {})
|
35
|
+
|
36
|
+
def __getitem__(self, item):
|
37
|
+
return self.config[item]
|
38
|
+
|
39
|
+
def get(self, key, default=None):
|
40
|
+
return self.config.get(key, default)
|
41
|
+
|
42
|
+
def __repr__(self):
|
43
|
+
return f"ActivationConfig(version='{self.version}', enabled={self.enabled}, description='{self.description}', type={self.type}, config={self.config})"
|
@@ -1,134 +1,134 @@
|
|
1
|
-
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
-
#
|
3
|
-
# SPDX-License-Identifier: MIT
|
4
|
-
|
5
|
-
import logging
|
6
|
-
import random
|
7
|
-
from datetime import datetime, timedelta
|
8
|
-
from timeit import default_timer as timer
|
9
|
-
from typing import Callable, Dict, Optional, Tuple
|
10
|
-
|
11
|
-
from .activation import ActivationType
|
12
|
-
from .communication import Status, StatusValue
|
13
|
-
|
14
|
-
|
15
|
-
class WrappedCallback:
|
16
|
-
def __init__(
|
17
|
-
self,
|
18
|
-
interval: timedelta,
|
19
|
-
callback: Callable,
|
20
|
-
logger: logging.Logger,
|
21
|
-
args: Optional[Tuple] = None,
|
22
|
-
kwargs: Optional[Dict] = None,
|
23
|
-
running_in_sim=False,
|
24
|
-
activation_type: Optional[ActivationType] = None,
|
25
|
-
):
|
26
|
-
self.callback: Callable = callback
|
27
|
-
if args is None:
|
28
|
-
args = ()
|
29
|
-
self.callback_args = args
|
30
|
-
if kwargs is None:
|
31
|
-
kwargs = {}
|
32
|
-
self.callback_kwargs = kwargs
|
33
|
-
self.interval: timedelta = interval
|
34
|
-
self.logger = logger
|
35
|
-
self.running: bool = False
|
36
|
-
self.status = Status(StatusValue.OK)
|
37
|
-
self.executions_total = 0 # global counter
|
38
|
-
self.executions_per_interval = 0 # counter per interval = 1 min by default
|
39
|
-
self.duration = 0 # global counter
|
40
|
-
self.duration_interval_total = 0 # counter per interval = 1 min by default
|
41
|
-
self.cluster_time_diff = 0
|
42
|
-
self.start_timestamp = self.get_current_time_with_cluster_diff()
|
43
|
-
self.running_in_sim = running_in_sim
|
44
|
-
self.activation_type = activation_type
|
45
|
-
self.ok_count = 0 # counter per interval = 1 min by default
|
46
|
-
self.timeouts_count = 0 # counter per interval = 1 min by default
|
47
|
-
self.exception_count = 0 # counter per interval = 1 min by default
|
48
|
-
|
49
|
-
def get_current_time_with_cluster_diff(self):
|
50
|
-
return datetime.now() + timedelta(milliseconds=self.cluster_time_diff)
|
51
|
-
|
52
|
-
def __call__(self):
|
53
|
-
self.logger.debug(f"Running scheduled callback {self}")
|
54
|
-
self.start_timestamp = self.get_current_time_with_cluster_diff()
|
55
|
-
self.running = True
|
56
|
-
self.executions_total += 1
|
57
|
-
self.executions_per_interval += 1
|
58
|
-
self.next_run = datetime.now() + self.interval
|
59
|
-
start_time = timer()
|
60
|
-
failed = False
|
61
|
-
try:
|
62
|
-
self.callback(*self.callback_args, **self.callback_kwargs)
|
63
|
-
self.status = Status(StatusValue.OK)
|
64
|
-
except Exception as e:
|
65
|
-
failed = True
|
66
|
-
self.logger.exception(f"Error running callback {self}: {e!r}")
|
67
|
-
self.status = Status(StatusValue.GENERIC_ERROR, repr(e))
|
68
|
-
self.exception_count += 1
|
69
|
-
|
70
|
-
self.running = False
|
71
|
-
self.duration = timer() - start_time
|
72
|
-
self.duration_interval_total += self.duration
|
73
|
-
self.logger.debug(f"Ran scheduled callback {self} in {self.duration:.2f} seconds")
|
74
|
-
if self.duration > self.interval.total_seconds():
|
75
|
-
message = f"Callback {self} took {self.duration:.4f} seconds to execute, which is longer than the interval of {self.interval.total_seconds()}s"
|
76
|
-
self.logger.warning(message)
|
77
|
-
self.status = Status(StatusValue.GENERIC_ERROR, message)
|
78
|
-
self.timeouts_count += 1
|
79
|
-
elif not failed:
|
80
|
-
self.ok_count += 1
|
81
|
-
|
82
|
-
def __repr__(self):
|
83
|
-
return f"Method={self.callback.__name__}"
|
84
|
-
|
85
|
-
def name(self):
|
86
|
-
return self.callback.__name__
|
87
|
-
|
88
|
-
def initial_wait_time(self) -> float:
|
89
|
-
if not self.running_in_sim:
|
90
|
-
"""
|
91
|
-
Here we chose a random second between 1 and 59 to start the callback
|
92
|
-
This is to distribute load for extension running on this host
|
93
|
-
When running from the simulator, this is not done
|
94
|
-
"""
|
95
|
-
|
96
|
-
now = self.get_current_time_with_cluster_diff()
|
97
|
-
random_second = random.randint(1, 59) # noqa: S311
|
98
|
-
next_execution = datetime.now().replace(second=random_second, microsecond=0)
|
99
|
-
if next_execution <= now:
|
100
|
-
# The random chosen second already passed this minute
|
101
|
-
next_execution += timedelta(minutes=1)
|
102
|
-
wait_time = (next_execution - now).total_seconds()
|
103
|
-
self.logger.debug(f"Randomly choosing next execution time for callback {self} to be {next_execution}")
|
104
|
-
return wait_time
|
105
|
-
return 0
|
106
|
-
|
107
|
-
def get_adjusted_metric_timestamp(self) -> datetime:
|
108
|
-
"""
|
109
|
-
Callbacks can't run all together, so they must start at random times
|
110
|
-
This means that when reporting metrics for a callback, we need to consider
|
111
|
-
the time the callback was started, instead of the current timestamp
|
112
|
-
this is done to avoid situations like:
|
113
|
-
- 14:00:55 - callback A runs
|
114
|
-
- 14:01:03 - 8 seconds later, a metric is reported
|
115
|
-
- 14:01:55 - callback A runs again (60 seconds after the first run)
|
116
|
-
- 14:01:58 - 3 seconds later a metric is reported
|
117
|
-
In this scenario a metric is reported twice in the same minute
|
118
|
-
This can also cause minutes where a metric is not reported at all, creating gaps
|
119
|
-
|
120
|
-
Here we calculate the metric timestamp based on the start timestamp of the callback
|
121
|
-
If the callback started in the last minute, we use the callback start timestamp
|
122
|
-
between 60 seconds and 120 seconds, we use the callback timestamp + 1 minute
|
123
|
-
between 120 seconds and 180 seconds, we use the callback timestamp + 2 minutes, and so forth
|
124
|
-
"""
|
125
|
-
now = self.get_current_time_with_cluster_diff()
|
126
|
-
minutes_since_start = int((now - self.start_timestamp).total_seconds() / 60)
|
127
|
-
return self.start_timestamp + timedelta(minutes=minutes_since_start)
|
128
|
-
|
129
|
-
def clear_sfm_metrics(self):
|
130
|
-
self.ok_count = 0
|
131
|
-
self.timeouts_count = 0
|
132
|
-
self.duration_interval_total = 0
|
133
|
-
self.exception_count = 0
|
134
|
-
self.executions_per_interval = 0
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Dynatrace LLC
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
|
5
|
+
import logging
|
6
|
+
import random
|
7
|
+
from datetime import datetime, timedelta
|
8
|
+
from timeit import default_timer as timer
|
9
|
+
from typing import Callable, Dict, Optional, Tuple
|
10
|
+
|
11
|
+
from .activation import ActivationType
|
12
|
+
from .communication import Status, StatusValue
|
13
|
+
|
14
|
+
|
15
|
+
class WrappedCallback:
|
16
|
+
def __init__(
|
17
|
+
self,
|
18
|
+
interval: timedelta,
|
19
|
+
callback: Callable,
|
20
|
+
logger: logging.Logger,
|
21
|
+
args: Optional[Tuple] = None,
|
22
|
+
kwargs: Optional[Dict] = None,
|
23
|
+
running_in_sim=False,
|
24
|
+
activation_type: Optional[ActivationType] = None,
|
25
|
+
):
|
26
|
+
self.callback: Callable = callback
|
27
|
+
if args is None:
|
28
|
+
args = ()
|
29
|
+
self.callback_args = args
|
30
|
+
if kwargs is None:
|
31
|
+
kwargs = {}
|
32
|
+
self.callback_kwargs = kwargs
|
33
|
+
self.interval: timedelta = interval
|
34
|
+
self.logger = logger
|
35
|
+
self.running: bool = False
|
36
|
+
self.status = Status(StatusValue.OK)
|
37
|
+
self.executions_total = 0 # global counter
|
38
|
+
self.executions_per_interval = 0 # counter per interval = 1 min by default
|
39
|
+
self.duration = 0 # global counter
|
40
|
+
self.duration_interval_total = 0 # counter per interval = 1 min by default
|
41
|
+
self.cluster_time_diff = 0
|
42
|
+
self.start_timestamp = self.get_current_time_with_cluster_diff()
|
43
|
+
self.running_in_sim = running_in_sim
|
44
|
+
self.activation_type = activation_type
|
45
|
+
self.ok_count = 0 # counter per interval = 1 min by default
|
46
|
+
self.timeouts_count = 0 # counter per interval = 1 min by default
|
47
|
+
self.exception_count = 0 # counter per interval = 1 min by default
|
48
|
+
|
49
|
+
def get_current_time_with_cluster_diff(self):
|
50
|
+
return datetime.now() + timedelta(milliseconds=self.cluster_time_diff)
|
51
|
+
|
52
|
+
def __call__(self):
|
53
|
+
self.logger.debug(f"Running scheduled callback {self}")
|
54
|
+
self.start_timestamp = self.get_current_time_with_cluster_diff()
|
55
|
+
self.running = True
|
56
|
+
self.executions_total += 1
|
57
|
+
self.executions_per_interval += 1
|
58
|
+
self.next_run = datetime.now() + self.interval
|
59
|
+
start_time = timer()
|
60
|
+
failed = False
|
61
|
+
try:
|
62
|
+
self.callback(*self.callback_args, **self.callback_kwargs)
|
63
|
+
self.status = Status(StatusValue.OK)
|
64
|
+
except Exception as e:
|
65
|
+
failed = True
|
66
|
+
self.logger.exception(f"Error running callback {self}: {e!r}")
|
67
|
+
self.status = Status(StatusValue.GENERIC_ERROR, repr(e))
|
68
|
+
self.exception_count += 1
|
69
|
+
|
70
|
+
self.running = False
|
71
|
+
self.duration = timer() - start_time
|
72
|
+
self.duration_interval_total += self.duration
|
73
|
+
self.logger.debug(f"Ran scheduled callback {self} in {self.duration:.2f} seconds")
|
74
|
+
if self.duration > self.interval.total_seconds():
|
75
|
+
message = f"Callback {self} took {self.duration:.4f} seconds to execute, which is longer than the interval of {self.interval.total_seconds()}s"
|
76
|
+
self.logger.warning(message)
|
77
|
+
self.status = Status(StatusValue.GENERIC_ERROR, message)
|
78
|
+
self.timeouts_count += 1
|
79
|
+
elif not failed:
|
80
|
+
self.ok_count += 1
|
81
|
+
|
82
|
+
def __repr__(self):
|
83
|
+
return f"Method={self.callback.__name__}"
|
84
|
+
|
85
|
+
def name(self):
|
86
|
+
return self.callback.__name__
|
87
|
+
|
88
|
+
def initial_wait_time(self) -> float:
|
89
|
+
if not self.running_in_sim:
|
90
|
+
"""
|
91
|
+
Here we chose a random second between 1 and 59 to start the callback
|
92
|
+
This is to distribute load for extension running on this host
|
93
|
+
When running from the simulator, this is not done
|
94
|
+
"""
|
95
|
+
|
96
|
+
now = self.get_current_time_with_cluster_diff()
|
97
|
+
random_second = random.randint(1, 59) # noqa: S311
|
98
|
+
next_execution = datetime.now().replace(second=random_second, microsecond=0)
|
99
|
+
if next_execution <= now:
|
100
|
+
# The random chosen second already passed this minute
|
101
|
+
next_execution += timedelta(minutes=1)
|
102
|
+
wait_time = (next_execution - now).total_seconds()
|
103
|
+
self.logger.debug(f"Randomly choosing next execution time for callback {self} to be {next_execution}")
|
104
|
+
return wait_time
|
105
|
+
return 0
|
106
|
+
|
107
|
+
def get_adjusted_metric_timestamp(self) -> datetime:
|
108
|
+
"""
|
109
|
+
Callbacks can't run all together, so they must start at random times
|
110
|
+
This means that when reporting metrics for a callback, we need to consider
|
111
|
+
the time the callback was started, instead of the current timestamp
|
112
|
+
this is done to avoid situations like:
|
113
|
+
- 14:00:55 - callback A runs
|
114
|
+
- 14:01:03 - 8 seconds later, a metric is reported
|
115
|
+
- 14:01:55 - callback A runs again (60 seconds after the first run)
|
116
|
+
- 14:01:58 - 3 seconds later a metric is reported
|
117
|
+
In this scenario a metric is reported twice in the same minute
|
118
|
+
This can also cause minutes where a metric is not reported at all, creating gaps
|
119
|
+
|
120
|
+
Here we calculate the metric timestamp based on the start timestamp of the callback
|
121
|
+
If the callback started in the last minute, we use the callback start timestamp
|
122
|
+
between 60 seconds and 120 seconds, we use the callback timestamp + 1 minute
|
123
|
+
between 120 seconds and 180 seconds, we use the callback timestamp + 2 minutes, and so forth
|
124
|
+
"""
|
125
|
+
now = self.get_current_time_with_cluster_diff()
|
126
|
+
minutes_since_start = int((now - self.start_timestamp).total_seconds() / 60)
|
127
|
+
return self.start_timestamp + timedelta(minutes=minutes_since_start)
|
128
|
+
|
129
|
+
def clear_sfm_metrics(self):
|
130
|
+
self.ok_count = 0
|
131
|
+
self.timeouts_count = 0
|
132
|
+
self.duration_interval_total = 0
|
133
|
+
self.exception_count = 0
|
134
|
+
self.executions_per_interval = 0
|