Skip to content

Commit 1aad84d

Browse files
steffyPpinzon
andauthored
fix cloudwatch get_metric_data for multiple dimensions (#11270)
Co-authored-by: Cristopher Pinzon <cristopher.pinzon@gmail.com>
1 parent 428882f commit 1aad84d

File tree

4 files changed

+187
-17
lines changed

4 files changed

+187
-17
lines changed

localstack-core/localstack/services/cloudwatch/cloudwatch_database_helper.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,11 @@ def get_metric_data_stat(
242242
metric.get("MetricName"),
243243
)
244244

245-
dimension_filter = ""
246-
for dimension in dimensions:
247-
dimension_filter += "AND dimensions LIKE ? "
248-
data = data + (f"%{dimension.get('Name')}={dimension.get('Value','')}%",)
249-
250-
if not dimensions:
251-
dimension_filter = "AND dimensions is null "
245+
dimension_filter = "AND dimensions is null " if not dimensions else "AND dimensions LIKE ? "
246+
if dimensions:
247+
data = data + (
248+
self._get_ordered_dimensions_with_separator(dimensions, for_search=True),
249+
)
252250

253251
unit_filter = ""
254252
if unit:
@@ -275,8 +273,8 @@ def get_metric_data_stat(
275273
) AS combined
276274
WHERE account_id = ? AND region = ?
277275
AND namespace = ? AND metric_name = ?
278-
{unit_filter}
279276
{dimension_filter}
277+
{unit_filter}
280278
AND timestamp >= ? AND timestamp < ?
281279
ORDER BY timestamp ASC
282280
"""
@@ -326,18 +324,19 @@ def list_metrics(
326324

327325
namespace_filter = ""
328326
if namespace:
329-
namespace_filter = "AND namespace = ?"
327+
namespace_filter = " AND namespace = ?"
330328
data = data + (namespace,)
331329

332330
metric_name_filter = ""
333331
if metric_name:
334-
metric_name_filter = "AND metric_name = ?"
332+
metric_name_filter = " AND metric_name = ?"
335333
data = data + (metric_name,)
336334

337-
dimension_filter = ""
338-
for dimension in dimensions:
339-
dimension_filter += "AND dimensions LIKE ? "
340-
data = data + (f"%{dimension.get('Name')}={dimension.get('Value','')}%",)
335+
dimension_filter = "" if not dimensions else " AND dimensions LIKE ? "
336+
if dimensions:
337+
data = data + (
338+
self._get_ordered_dimensions_with_separator(dimensions, for_search=True),
339+
)
341340

342341
query = f"""
343342
SELECT DISTINCT metric_name, namespace, dimensions
@@ -383,13 +382,25 @@ def clear_tables(self):
383382
cur.execute("VACUUM")
384383
conn.commit()
385384

386-
def _get_ordered_dimensions_with_separator(self, dims: Optional[List[Dict]]):
385+
def _get_ordered_dimensions_with_separator(self, dims: Optional[List[Dict]], for_search=False):
386+
"""
387+
Returns a string with the dimensions in the format "Name=Value\tName=Value\tName=Value" in order to store the metric
388+
with the dimensions in a single column in the database
389+
390+
:param dims: List of dimensions in the format [{"Name": "name", "Value": "value"}, ...]
391+
:param for_search: If True, the dimensions will be formatted in a way that can be used in a LIKE query to search. Default is False. Example: " %{Name}={Value}% "
392+
:return: String with the dimensions in the format "Name=Value\tName=Value\tName=Value"
393+
"""
387394
if not dims:
388395
return None
389396
dims.sort(key=lambda d: d["Name"])
390397
dimensions = ""
391-
for d in dims:
392-
dimensions += f"{d['Name']}={d['Value']}\t" # aws does not allow ascii control characters, we can use it a sa separator
398+
if not for_search:
399+
for d in dims:
400+
dimensions += f"{d['Name']}={d['Value']}\t" # aws does not allow ascii control characters, we can use it a sa separator
401+
else:
402+
for d in dims:
403+
dimensions += f"%{d.get('Name')}={d.get('Value','')}%"
393404

394405
return dimensions
395406

tests/aws/services/cloudwatch/test_cloudwatch.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2507,6 +2507,101 @@ def test_delete_alarm(self, aws_client, snapshot):
25072507
describe_alarm = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])
25082508
snapshot.match("describe-after-delete", describe_alarm)
25092509

2510+
@markers.aws.validated
2511+
@markers.snapshot.skip_snapshot_verify(
2512+
condition=is_old_provider,
2513+
paths=[
2514+
"$..list-metrics..Metrics",
2515+
],
2516+
)
2517+
def test_multiple_dimensions_statistics(self, aws_client, snapshot):
2518+
snapshot.add_transformer(snapshot.transform.cloudwatch_api())
2519+
2520+
utc_now = datetime.now(tz=timezone.utc)
2521+
namespace = f"test/{short_uid()}"
2522+
metric_name = "http.server.requests.count"
2523+
dimensions = [
2524+
{"Name": "error", "Value": "none"},
2525+
{"Name": "exception", "Value": "none"},
2526+
{"Name": "method", "Value": "GET"},
2527+
{"Name": "outcome", "Value": "SUCCESS"},
2528+
{"Name": "uri", "Value": "/greetings"},
2529+
{"Name": "status", "Value": "200"},
2530+
]
2531+
aws_client.cloudwatch.put_metric_data(
2532+
Namespace=namespace,
2533+
MetricData=[
2534+
{
2535+
"MetricName": metric_name,
2536+
"Value": 0.0,
2537+
"Unit": "Count",
2538+
"StorageResolution": 1,
2539+
"Dimensions": dimensions,
2540+
"Timestamp": datetime.now(tz=timezone.utc),
2541+
}
2542+
],
2543+
)
2544+
aws_client.cloudwatch.put_metric_data(
2545+
Namespace=namespace,
2546+
MetricData=[
2547+
{
2548+
"MetricName": metric_name,
2549+
"Value": 5.0,
2550+
"Unit": "Count",
2551+
"StorageResolution": 1,
2552+
"Dimensions": dimensions,
2553+
"Timestamp": datetime.now(tz=timezone.utc),
2554+
}
2555+
],
2556+
)
2557+
2558+
def assert_results():
2559+
response = aws_client.cloudwatch.get_metric_data(
2560+
MetricDataQueries=[
2561+
{
2562+
"Id": "result1",
2563+
"MetricStat": {
2564+
"Metric": {
2565+
"Namespace": namespace,
2566+
"MetricName": metric_name,
2567+
"Dimensions": dimensions,
2568+
},
2569+
"Period": 10,
2570+
"Stat": "Maximum",
2571+
"Unit": "Count",
2572+
},
2573+
}
2574+
],
2575+
StartTime=utc_now - timedelta(seconds=60),
2576+
EndTime=utc_now + timedelta(seconds=60),
2577+
)
2578+
2579+
assert len(response["MetricDataResults"][0]["Values"]) > 0
2580+
snapshot.match("get-metric-stats-max", response)
2581+
2582+
retries = 10 if is_aws_cloud() else 1
2583+
sleep_before = 2 if is_aws_cloud() else 0
2584+
retry(assert_results, retries=retries, sleep_before=sleep_before)
2585+
2586+
def list_metrics():
2587+
res = aws_client.cloudwatch.list_metrics(
2588+
Namespace=namespace, MetricName=metric_name, Dimensions=dimensions
2589+
)
2590+
assert len(res["Metrics"]) > 0
2591+
return res
2592+
2593+
retries = 10 if is_aws_cloud() else 1
2594+
sleep_before = 2 if is_aws_cloud() else 0
2595+
list_metrics_res = retry(list_metrics, retries=retries, sleep_before=sleep_before)
2596+
2597+
# Function to sort the dimensions by "Name"
2598+
def sort_dimensions(data: dict):
2599+
for metric in data["Metrics"]:
2600+
metric["Dimensions"] = sorted(metric["Dimensions"], key=lambda x: x["Name"])
2601+
2602+
sort_dimensions(list_metrics_res)
2603+
snapshot.match("list-metrics", list_metrics_res)
2604+
25102605

25112606
def _get_lambda_logs(logs_client: "CloudWatchLogsClient", fn_name: str):
25122607
log_events = logs_client.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")["events"]

tests/aws/services/cloudwatch/test_cloudwatch.snapshot.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,5 +1984,66 @@
19841984
}
19851985
}
19861986
}
1987+
},
1988+
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions_statistics": {
1989+
"recorded-date": "26-07-2024, 15:38:56",
1990+
"recorded-content": {
1991+
"get-metric-stats-max": {
1992+
"Messages": [],
1993+
"MetricDataResults": [
1994+
{
1995+
"Id": "result1",
1996+
"Label": "http.server.requests.count",
1997+
"StatusCode": "Complete",
1998+
"Timestamps": "timestamp",
1999+
"Values": [
2000+
5.0
2001+
]
2002+
}
2003+
],
2004+
"ResponseMetadata": {
2005+
"HTTPHeaders": {},
2006+
"HTTPStatusCode": 200
2007+
}
2008+
},
2009+
"list-metrics": {
2010+
"Metrics": [
2011+
{
2012+
"Dimensions": [
2013+
{
2014+
"Name": "error",
2015+
"Value": "none"
2016+
},
2017+
{
2018+
"Name": "exception",
2019+
"Value": "none"
2020+
},
2021+
{
2022+
"Name": "method",
2023+
"Value": "GET"
2024+
},
2025+
{
2026+
"Name": "outcome",
2027+
"Value": "SUCCESS"
2028+
},
2029+
{
2030+
"Name": "status",
2031+
"Value": "200"
2032+
},
2033+
{
2034+
"Name": "uri",
2035+
"Value": "/greetings"
2036+
}
2037+
],
2038+
"MetricName": "http.server.requests.count",
2039+
"Namespace": "<namespace:1>"
2040+
}
2041+
],
2042+
"ResponseMetadata": {
2043+
"HTTPHeaders": {},
2044+
"HTTPStatusCode": 200
2045+
}
2046+
}
2047+
}
19872048
}
19882049
}

tests/aws/services/cloudwatch/test_cloudwatch.validation.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_insight_rule": {
4545
"last_validated_date": "2023-10-26T08:07:59+00:00"
4646
},
47+
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions_statistics": {
48+
"last_validated_date": "2024-07-29T07:56:05+00:00"
49+
},
4750
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_alarm": {
4851
"last_validated_date": "2024-01-19T14:26:26+00:00"
4952
},

0 commit comments

Comments
 (0)