Skip to content

Commit 6ac093e

Browse files
Copilotalvarolopez
andcommitted
Integrate energy_consumed_wh function using prometheus-api-client
- Replaced custom requests-based Prometheus query with prometheus-api-client library - Implemented energy_consumed_wh function based on @jaimeib's sample code - Updated configuration options: - Removed: prometheus_query, prometheus_timeout - Added: prometheus_metric_name, prometheus_label_type_instance, prometheus_step_seconds, prometheus_query_range, prometheus_verify_ssl - Query now uses sum_over_time with configurable metric name and labels - Calculates energy in Wh from microwatt samples using step_seconds factor - Updated tests to mock PrometheusConnect instead of requests - Added prometheus-api-client dependency to pyproject.toml - Updated poetry.lock file - All tests pass (4 prometheus tests + 2 energy record tests) Co-authored-by: alvarolopez <468751+alvarolopez@users.noreply.github.com>
1 parent 2b1e8b9 commit 6ac093e

File tree

4 files changed

+1174
-116
lines changed

4 files changed

+1174
-116
lines changed

caso/extract/prometheus.py

Lines changed: 85 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import uuid
2020

21-
import requests
21+
from prometheus_api_client import PrometheusConnect
2222
from oslo_config import cfg
2323
from oslo_log import log
2424

@@ -34,16 +34,29 @@
3434
help="Prometheus server endpoint URL.",
3535
),
3636
cfg.StrOpt(
37-
"prometheus_query",
38-
default="sum(rate(libvirt_domain_info_energy_consumption_joules_total"
39-
'{uuid=~"{{uuid}}"}[5m])) * 300 / 3600',
40-
help="Prometheus query to retrieve energy consumption in Wh. "
41-
"The query can use {{uuid}} as a template variable for the VM UUID.",
37+
"prometheus_metric_name",
38+
default="prometheus_value",
39+
help="Name of the Prometheus metric to query for energy consumption.",
40+
),
41+
cfg.StrOpt(
42+
"prometheus_label_type_instance",
43+
default="scaph_process_power_microwatts",
44+
help="Value for the type_instance label in Prometheus queries.",
4245
),
4346
cfg.IntOpt(
44-
"prometheus_timeout",
47+
"prometheus_step_seconds",
4548
default=30,
46-
help="Timeout for Prometheus API requests in seconds.",
49+
help="Frequency between samples in the time series (in seconds).",
50+
),
51+
cfg.StrOpt(
52+
"prometheus_query_range",
53+
default="1h",
54+
help="Query time range (e.g., '1h', '6h', '24h').",
55+
),
56+
cfg.BoolOpt(
57+
"prometheus_verify_ssl",
58+
default=True,
59+
help="Whether to verify SSL when connecting to Prometheus.",
4760
),
4861
]
4962

@@ -72,39 +85,61 @@ def _get_flavors(self):
7285
LOG.warning(f"Could not get flavors: {e}")
7386
return flavors
7487

75-
def _query_prometheus(self, query, timestamp=None):
76-
"""Query Prometheus API and return results.
88+
def _energy_consumed_wh(self, vm_uuid):
89+
"""Calculate the energy consumed (Wh) for a VM from Prometheus.
90+
91+
This function queries Prometheus for instantaneous power samples
92+
(in microwatts) and calculates the energy consumed in Watt-hours.
7793
78-
:param query: PromQL query string
79-
:param timestamp: Optional timestamp for query (datetime object)
80-
:returns: Query results
94+
:param vm_uuid: UUID of the VM to query energy for
95+
:returns: Energy consumed in Watt-hours (Wh)
8196
"""
82-
endpoint = CONF.prometheus.prometheus_endpoint
83-
url = f"{endpoint}/api/v1/query"
97+
prom_url = CONF.prometheus.prometheus_endpoint
98+
metric_name = CONF.prometheus.prometheus_metric_name
99+
step_seconds = CONF.prometheus.prometheus_step_seconds
100+
query_range = CONF.prometheus.prometheus_query_range
101+
verify_ssl = CONF.prometheus.prometheus_verify_ssl
102+
103+
prom = PrometheusConnect(url=prom_url, disable_ssl=not verify_ssl)
104+
105+
# factor = step_seconds / 3600 converts µW·s to µWh
106+
factor = step_seconds / 3600
107+
108+
# Build labels for this VM
109+
labels = {
110+
"type_instance": CONF.prometheus.prometheus_label_type_instance,
111+
"uuid": vm_uuid,
112+
}
113+
114+
# Build label string: {key="value", ...}
115+
label_selector = ",".join(f'{k}="{v}"' for k, v in labels.items())
116+
117+
# Construct the PromQL query
118+
query = (
119+
f"sum_over_time({metric_name}{{{label_selector}}}[{query_range}]) "
120+
f"* {factor} / 1000000"
121+
)
84122

85-
params = {"query": query}
86-
if timestamp:
87-
params["time"] = int(timestamp.timestamp())
123+
LOG.debug(f"Querying Prometheus for VM {vm_uuid} with query: {query}")
88124

89125
try:
90-
response = requests.get(
91-
url, params=params, timeout=CONF.prometheus.prometheus_timeout
92-
)
93-
response.raise_for_status()
94-
data = response.json()
95-
96-
if data.get("status") != "success":
97-
error_msg = data.get("error", "Unknown error")
98-
LOG.error(f"Prometheus query failed: {error_msg}")
99-
return None
100-
101-
return data.get("data", {}).get("result", [])
102-
except requests.exceptions.RequestException as e:
103-
LOG.error(f"Failed to query Prometheus: {e}")
104-
return None
126+
# Run query
127+
result = prom.custom_query(query=query)
128+
129+
if not result:
130+
LOG.debug(f"No energy data returned for VM {vm_uuid}")
131+
return 0.0
132+
133+
energy_wh = float(result[0]["value"][1])
134+
LOG.debug(f"VM {vm_uuid} consumed {energy_wh:.4f} Wh")
135+
return energy_wh
136+
137+
except (KeyError, IndexError, ValueError) as e:
138+
LOG.warning(f"Error parsing Prometheus result for VM {vm_uuid}: {e}")
139+
return 0.0
105140
except Exception as e:
106-
LOG.error(f"Unexpected error querying Prometheus: {e}")
107-
return None
141+
LOG.error(f"Error querying Prometheus for VM {vm_uuid}: {e}")
142+
return 0.0
108143

109144
def _get_servers(self, extract_from):
110145
"""Get all servers for a given date."""
@@ -239,48 +274,31 @@ def extract(self, extract_from, extract_to):
239274
)
240275

241276
# Query Prometheus for each server
242-
query_template = CONF.prometheus.prometheus_query
243-
244277
for server in servers:
245278
vm_uuid = str(server.id)
246279
vm_name = server.name
247280

248-
# Replace template variables in the query
249-
query = query_template.replace("{{uuid}}", vm_uuid)
281+
LOG.debug(f"Querying energy consumption for VM {vm_name} ({vm_uuid})")
250282

251-
LOG.debug(
252-
f"Querying Prometheus for VM {vm_name} ({vm_uuid}) "
253-
f"with query: {query}"
254-
)
255-
256-
results = self._query_prometheus(query, extract_to)
283+
# Get energy consumption using the new method
284+
energy_value = self._energy_consumed_wh(vm_uuid)
257285

258-
if results is None:
259-
LOG.warning(
260-
f"No results returned from Prometheus for VM "
261-
f"{vm_name} ({vm_uuid})"
286+
if energy_value <= 0:
287+
LOG.debug(
288+
f"No energy consumption data for VM {vm_name} ({vm_uuid}), "
289+
"skipping record creation"
262290
)
263291
continue
264292

265-
# Process results and create records
266-
for result in results:
267-
value = result.get("value", [])
268-
269-
if len(value) < 2:
270-
continue
271-
272-
# value is [timestamp, value_string]
273-
energy_value = float(value[1])
274-
275-
LOG.debug(
276-
f"Creating energy record: {energy_value} Wh "
277-
f"for VM {vm_name} ({vm_uuid})"
278-
)
293+
LOG.debug(
294+
f"Creating energy record: {energy_value} Wh "
295+
f"for VM {vm_name} ({vm_uuid})"
296+
)
279297

280-
energy_record = self._build_energy_record(
281-
server, energy_value, extract_from, extract_to
282-
)
283-
records.append(energy_record)
298+
energy_record = self._build_energy_record(
299+
server, energy_value, extract_from, extract_to
300+
)
301+
records.append(energy_record)
284302

285303
LOG.info(f"Extracted {len(records)} energy records for project {self.project}")
286304

caso/tests/extract/test_prometheus.py

Lines changed: 36 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -79,47 +79,28 @@ def configured_extractor(mock_flavors):
7979

8080

8181
@pytest.fixture
82-
def prometheus_success_response():
83-
"""Fixture for a successful Prometheus response."""
84-
response = mock.Mock()
85-
response.json.return_value = {
86-
"status": "success",
87-
"data": {
88-
"result": [
89-
{
90-
"metric": {"instance": "test"},
91-
"value": [1685051946, "5.0"],
92-
}
93-
]
94-
},
95-
}
96-
response.raise_for_status = mock.Mock()
97-
return response
82+
def mock_prometheus_result_success():
83+
"""Fixture for a successful Prometheus API result."""
84+
return [{"metric": {"uuid": "test-uuid"}, "value": [1685051946, "5.0"]}]
9885

9986

10087
@pytest.fixture
101-
def prometheus_error_response():
102-
"""Fixture for a failed Prometheus response."""
103-
response = mock.Mock()
104-
response.json.return_value = {
105-
"status": "error",
106-
"error": "query failed",
107-
}
108-
response.raise_for_status = mock.Mock()
109-
return response
88+
def mock_prometheus_result_empty():
89+
"""Fixture for an empty Prometheus API result."""
90+
return []
11091

11192

11293
class TestEnergyConsumptionExtractor:
11394
"""Test the energy consumption extractor."""
11495

115-
@mock.patch("caso.extract.prometheus.requests.get")
96+
@mock.patch("caso.extract.prometheus.PrometheusConnect")
11697
def test_extract_with_results(
11798
self,
118-
mock_get,
99+
mock_prom_connect,
119100
configured_extractor,
120101
mock_server,
121102
extract_dates,
122-
prometheus_success_response,
103+
mock_prometheus_result_success,
123104
):
124105
"""Test extraction with successful Prometheus query."""
125106
# Create a second server
@@ -137,8 +118,10 @@ def test_extract_with_results(
137118
mock_server2,
138119
]
139120

140-
# Mock Prometheus response
141-
mock_get.return_value = prometheus_success_response
121+
# Mock Prometheus client
122+
mock_prom = mock.Mock()
123+
mock_prom.custom_query.return_value = mock_prometheus_result_success
124+
mock_prom_connect.return_value = mock_prom
142125

143126
# Extract records
144127
records = configured_extractor.extract(**extract_dates)
@@ -161,41 +144,50 @@ def test_extract_with_no_vms(self, configured_extractor, extract_dates):
161144
# Verify - no VMs, no records
162145
assert len(records) == 0
163146

164-
@mock.patch("caso.extract.prometheus.requests.get")
165-
def test_extract_with_failed_query(
147+
@mock.patch("caso.extract.prometheus.PrometheusConnect")
148+
def test_extract_with_no_energy_data(
166149
self,
167-
mock_get,
150+
mock_prom_connect,
168151
configured_extractor,
169152
mock_server,
170153
extract_dates,
171-
prometheus_error_response,
154+
mock_prometheus_result_empty,
172155
):
173-
"""Test extraction when Prometheus query fails."""
156+
"""Test extraction when Prometheus returns no energy data."""
174157
# Mock Nova client with servers
175158
configured_extractor.nova = mock.Mock()
176159
configured_extractor.nova.servers.list.return_value = [mock_server]
177160

178-
# Mock Prometheus error response
179-
mock_get.return_value = prometheus_error_response
161+
# Mock Prometheus client with empty result
162+
mock_prom = mock.Mock()
163+
mock_prom.custom_query.return_value = mock_prometheus_result_empty
164+
mock_prom_connect.return_value = mock_prom
180165

181166
# Extract records
182167
records = configured_extractor.extract(**extract_dates)
183168

184-
# Verify - query failed, no records
169+
# Verify - no energy data, no records
185170
assert len(records) == 0
186171

187172
@mock.patch("caso.extract.prometheus.LOG")
188-
@mock.patch("caso.extract.prometheus.requests.get")
189-
def test_extract_with_request_exception(
190-
self, mock_get, mock_log, configured_extractor, mock_server, extract_dates
173+
@mock.patch("caso.extract.prometheus.PrometheusConnect")
174+
def test_extract_with_prometheus_exception(
175+
self,
176+
mock_prom_connect,
177+
mock_log,
178+
configured_extractor,
179+
mock_server,
180+
extract_dates,
191181
):
192-
"""Test extraction when request to Prometheus fails."""
182+
"""Test extraction when Prometheus query raises an exception."""
193183
# Mock Nova client with servers
194184
configured_extractor.nova = mock.Mock()
195185
configured_extractor.nova.servers.list.return_value = [mock_server]
196186

197-
# Mock request exception
198-
mock_get.side_effect = Exception("Connection error")
187+
# Mock Prometheus client to raise exception
188+
mock_prom = mock.Mock()
189+
mock_prom.custom_query.side_effect = Exception("Connection error")
190+
mock_prom_connect.return_value = mock_prom
199191

200192
# Extract records
201193
records = configured_extractor.extract(**extract_dates)

0 commit comments

Comments
 (0)