Skip to content

Commit cec1383

Browse files
authored
Merge pull request #458 from powerapi-ng/feat/report-processor-tags
feat(report): Allow metadata to be sanitized and flattened
2 parents 3e61a02 + 015ebf1 commit cec1383

File tree

6 files changed

+248
-406
lines changed

6 files changed

+248
-406
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
22
max-line-length = 160
3-
ignore = W504, F401, F811
3+
ignore = W504, F401, F811, E501
44
exclude = powerapi/test_utils

src/powerapi/report/power_report.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -151,45 +151,44 @@ def to_virtiofs_db(report: PowerReport) -> Tuple[str, str]:
151151
power = report.power
152152
return filename, power
153153

154-
def gen_tag(self, metadata_kept):
154+
def generate_tags(self, selected_tags: None | list[str] = None) -> dict[str, Any]:
155155
"""
156-
Generate the tags list of the report.
157-
:param metadata_kept: The metadata to keep
156+
Generate the report tags from its metadata.
157+
:param selected_tags: List of tags to be included (in flattened/sanitized form), None to include everything
158+
:return: a single level dictionary containing the tags of the report
158159
"""
159-
# Always sensor and target are kept
160-
tags = {'sensor': self.sensor, 'target': self.target}
160+
flattened_tags = self.flatten_tags(self.metadata)
161+
sanitized_tags_name = self.sanitize_tags_name(flattened_tags)
162+
sanitized_tags = {sanitized_tags_name[k]: v for k, v in flattened_tags.items()}
161163

162-
if metadata_kept:
163-
for metadata_name in metadata_kept:
164-
if metadata_name not in self.metadata:
165-
raise BadInputData(f'No tag "{metadata_name}" found in power report', self)
166-
tags[metadata_name] = self.metadata[metadata_name]
164+
if selected_tags:
165+
tags = {k: v for k, v in sanitized_tags.items() if k in selected_tags}
167166
else:
168-
tags.update(self.metadata)
167+
tags = sanitized_tags
169168

170-
return tags
169+
return {'sensor': self.sensor, 'target': self.target} | tags
171170

172171
@staticmethod
173-
def to_influxdb(report: PowerReport, tags: List[str]) -> Dict:
172+
def to_influxdb(report: PowerReport, tags: None | list[str]) -> dict[str, Any]:
174173
"""
175174
:return: a dictionary, that can be stored into an influxdb, from a given PowerReport
176175
"""
177176
return {
178177
'measurement': 'power_consumption',
179-
'tags': report.gen_tag(tags),
178+
'tags': report.generate_tags(tags),
180179
'time': str(report.timestamp),
181180
'fields': {
182181
'power': report.power
183182
}
184183
}
185184

186185
@staticmethod
187-
def to_prometheus(report: PowerReport, tags: List[str]) -> Dict:
186+
def to_prometheus(report: PowerReport, tags: None | list[str]) -> dict[str, Any]:
188187
"""
189188
:return: a dictionary, that can be stored into a prometheus instance, from a given PowerReport
190189
"""
191190
return {
192-
'tags': report.gen_tag(tags),
191+
'tags': report.generate_tags(tags),
193192
'time': int(report.timestamp.timestamp()),
194193
'value': report.power
195194
}

src/powerapi/report/report.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@
2929

3030
from __future__ import annotations
3131

32+
from collections import Counter
3233
from datetime import datetime
33-
from typing import Dict, NewType, Tuple, List, Any
34+
from typing import Dict, NewType, Tuple, List, Any, Iterable
35+
from zlib import crc32
36+
3437
from powerapi.exception import PowerAPIExceptionWithMessage, PowerAPIException
3538
from powerapi.message import Message
3639

@@ -43,6 +46,8 @@
4346
CSV_HEADER_COMMON = [TIMESTAMP_KEY, SENSOR_KEY, TARGET_KEY]
4447
CsvLines = NewType('CsvLines', Tuple[List[str], Dict[str, str]])
4548

49+
TAGS_NAME_TRANSLATION_TABLE = str.maketrans('.-/', '___')
50+
4651

4752
class BadInputData(PowerAPIExceptionWithMessage):
4853
"""
@@ -133,3 +138,40 @@ def create_empty_report():
133138
Creates an empty report
134139
"""
135140
return Report(None, None, None)
141+
142+
@staticmethod
143+
def sanitize_tags_name(tags: Iterable[str]) -> dict[str, str]:
144+
"""
145+
Generate a dict containing the tags name and theirs corresponding sanitized version.
146+
147+
The tags name are sanitized according to InfluxDB and Prometheus restrictions.
148+
If a sanitized tag have conflicts (`tag-name` and `tag.name` -> `tag_name`) a hash of the input tag will be
149+
appended at the end of the sanitized tag name. This allows to have stable tags name in the destination database.
150+
:param tags: Iterable object containing the tags name
151+
:return: Dictionary containing the input tag name as key and its sanitized version as value
152+
"""
153+
sanitized_tags = {tag: tag.translate(TAGS_NAME_TRANSLATION_TABLE) for tag in tags}
154+
conflict_count = Counter(sanitized_tags.values())
155+
return {
156+
tag_orig: (tag_new if conflict_count[tag_new] == 1 else f'{tag_new}_{crc32(tag_orig.encode()):x}')
157+
for tag_orig, tag_new in sanitized_tags.items()
158+
}
159+
160+
@staticmethod
161+
def flatten_tags(tags: dict[str, Any], separator: str = '_') -> dict[str, Any]:
162+
"""
163+
Flatten nested dictionaries within a tags dictionary.
164+
165+
This method takes a dictionary of tags, which may contain nested dictionaries as values, and flattens them into
166+
a single-level dictionary. Each key in the flattened dictionary is constructed by concatenating the keys from
167+
the nested dictionaries with their parent keys, separated by the specified separator.
168+
169+
This is particularly useful for databases that only support canonical (non-nested) types as values.
170+
:param tags: Input tags dict
171+
:param separator: Separator to use for the flattened tags name
172+
:return: Flattened tags dict
173+
"""
174+
return {
175+
f"{pkey}{separator}{ckey}" if isinstance(pvalue, dict) else pkey: cvalue for pkey, pvalue in tags.items()
176+
for ckey, cvalue in (pvalue.items() if isinstance(pvalue, dict) else {pkey: pvalue}.items())
177+
}

tests/unit/report/conftest.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,47 +26,68 @@
2626
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
2727
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2828
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
from datetime import datetime
30+
2931
import pytest
3032

3133
from powerapi.report import PowerReport
32-
from tests.utils.report.power import gen_json_power_report
3334

3435

3536
@pytest.fixture
3637
def power_report_without_metadata() -> PowerReport:
3738
"""
3839
Generates a power_power
3940
"""
40-
json_input = gen_json_power_report(1)[0]
41-
report = PowerReport.from_json(json_input)
42-
43-
return report
41+
ts = datetime(2020, 1, 1, 0, 0, 0)
42+
sensor = 'pytest'
43+
target = 'test'
44+
power = 42
45+
metadata = {}
46+
return PowerReport(ts, sensor, target, power, metadata)
4447

4548

4649
@pytest.fixture
4750
def power_report_with_metadata(power_report_without_metadata) -> PowerReport:
4851
"""
49-
Generates a power_power
52+
Generates a power report with single-level metadata.
5053
"""
51-
power_report_without_metadata.metadata = {'k1': 'v1',
52-
'k2': 'v2',
53-
'k3': 333,
54-
'k4': 'vv4'}
55-
54+
power_report_without_metadata.metadata = {
55+
'scope': 'cpu',
56+
'socket': 0,
57+
'formula': '0000000000000000000000000000000000000000'
58+
}
5659
return power_report_without_metadata
5760

5861

5962
@pytest.fixture
60-
def power_report_with_nested_metadata(power_report_without_metadata) -> PowerReport:
63+
def power_report_with_metadata_expected_tags(power_report_with_metadata) -> set[str]:
6164
"""
62-
Generates a power_power
65+
Returns the expected tags for the power report with single-level metadata.
6366
"""
64-
power_report_without_metadata.metadata = {'k1': {'k1_k1': 1},
65-
'k2': 'v2',
66-
'k3': 333,
67-
'k4': {'k4_k1': 'v1',
68-
'k4_k2': {'k4_k2_k1': 'v2'}
69-
}
70-
}
67+
return {'sensor', 'target', 'scope', 'socket', 'formula'}
7168

69+
70+
@pytest.fixture
71+
def power_report_with_nested_metadata(power_report_without_metadata) -> PowerReport:
72+
"""
73+
Generates a power report with nested metadata.
74+
"""
75+
power_report_without_metadata.metadata = {
76+
'scope': 'cpu',
77+
'socket': 0,
78+
'formula': '0000000000000000000000000000000000000000',
79+
'k8s': {
80+
'app.kubernetes.io/name': 'test',
81+
'app.kubernetes.io/managed-by': 'pytest',
82+
'helm.sh/chart': 'powerapi-pytest-1.0.0'
83+
}
84+
}
7285
return power_report_without_metadata
86+
87+
88+
@pytest.fixture
89+
def power_report_with_nested_metadata_expected_tags(power_report_with_nested_metadata) -> set[str]:
90+
"""
91+
Returns the expected tags for the power report with nested metadata.
92+
"""
93+
return {'sensor', 'target', 'scope', 'socket', 'formula', 'k8s_app_kubernetes_io_name', 'k8s_app_kubernetes_io_managed_by', 'k8s_helm_sh_chart'}

0 commit comments

Comments
 (0)