Skip to content

Commit df9e900

Browse files
committed
feat(metrics): add support for multiple dimension sets
Implements add_dimensions() method to create multiple dimension sets in CloudWatch EMF output, enabling metric aggregation across different dimension combinations. - Add dimension_sets list to track multiple dimension arrays - Implement add_dimensions() in AmazonCloudWatchEMFProvider - Update serialize_metric_set to output all dimension sets - Add add_dimensions() to Metrics wrapper class - Update clear_metrics to clear dimension_sets - Add comprehensive test suite (13 tests) - Handle dimension key conflicts (last value wins) - Include default dimensions in all dimension sets Resolves #6198
1 parent 1041188 commit df9e900

File tree

3 files changed

+336
-4
lines changed

3 files changed

+336
-4
lines changed

aws_lambda_powertools/metrics/metrics.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ def add_metric(
123123
def add_dimension(self, name: str, value: str) -> None:
124124
self.provider.add_dimension(name=name, value=value)
125125

126+
def add_dimensions(self, dimensions: dict[str, str]) -> None:
127+
"""Add a new set of dimensions creating an additional dimension array.
128+
129+
Creates a new dimension set in the CloudWatch EMF Dimensions array.
130+
"""
131+
self.provider.add_dimensions(dimensions=dimensions)
132+
126133
def serialize_metric_set(
127134
self,
128135
metrics: dict | None = None,

aws_lambda_powertools/metrics/provider/cloudwatch_emf/cloudwatch.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(
9494

9595
self.metadata_set = metadata_set if metadata_set is not None else {}
9696
self.timestamp: int | None = None
97+
self.dimension_sets: list[dict[str, str]] = [] # Store multiple dimension sets
9798

9899
self._metric_units = [unit.value for unit in MetricUnit]
99100
self._metric_unit_valid_options = list(MetricUnit.__members__)
@@ -256,21 +257,30 @@ def serialize_metric_set(
256257

257258
metric_names_and_values.update({metric_name: metric_value})
258259

260+
# Build Dimensions array: primary set + additional dimension sets
261+
dimension_arrays: list[list[str]] = [list(dimensions.keys())]
262+
all_dimensions: dict[str, str] = dict(dimensions)
263+
264+
# Add each additional dimension set
265+
for dim_set in self.dimension_sets:
266+
all_dimensions.update(dim_set)
267+
dimension_arrays.append(list(dim_set.keys()))
268+
259269
return {
260270
"_aws": {
261271
"Timestamp": self.timestamp or int(datetime.datetime.now().timestamp() * 1000), # epoch
262272
"CloudWatchMetrics": [
263273
{
264274
"Namespace": self.namespace, # "test_namespace"
265-
"Dimensions": [list(dimensions.keys())], # [ "service" ]
275+
"Dimensions": dimension_arrays, # [["service"], ["env", "region"]]
266276
"Metrics": metric_definition,
267277
},
268278
],
269279
},
270280
# NOTE: Mypy doesn't recognize splats '** syntax' in TypedDict
271-
**dimensions, # "service": "test_service"
272-
**metadata, # type: ignore[typeddict-item] # "username": "test"
273-
**metric_names_and_values, # "single_metric": 1.0
281+
**all_dimensions, # type: ignore[typeddict-item] # All dimension key-value pairs
282+
**metadata, # type: ignore[typeddict-item]
283+
**metric_names_and_values,
274284
}
275285

276286
def add_dimension(self, name: str, value: str) -> None:
@@ -316,6 +326,55 @@ def add_dimension(self, name: str, value: str) -> None:
316326

317327
self.dimension_set[name] = value
318328

329+
def add_dimensions(self, dimensions: dict[str, str]) -> None:
330+
"""Add a new set of dimensions creating an additional dimension array.
331+
332+
Creates a new dimension set in the CloudWatch EMF Dimensions array.
333+
"""
334+
logger.debug(f"Adding dimension set: {dimensions}")
335+
336+
if not dimensions:
337+
warnings.warn(
338+
"Empty dimensions dictionary provided",
339+
category=PowertoolsUserWarning,
340+
stacklevel=2,
341+
)
342+
return
343+
344+
# Convert values to strings and validate
345+
sanitized: dict[str, str] = {}
346+
for name, value in dimensions.items():
347+
str_value = value if isinstance(value, str) else str(value)
348+
str_name = name if isinstance(name, str) else str(name)
349+
350+
if not str_name.strip() or not str_value.strip():
351+
warnings.warn(
352+
f"Dimension {str_name} has empty name or value",
353+
category=PowertoolsUserWarning,
354+
stacklevel=2,
355+
)
356+
continue
357+
358+
sanitized[str_name] = str_value
359+
360+
if not sanitized:
361+
return
362+
363+
# Count unique dimensions across all sets
364+
all_keys = set(self.dimension_set.keys())
365+
for ds in self.dimension_sets:
366+
all_keys.update(ds.keys())
367+
all_keys.update(sanitized.keys())
368+
369+
if len(all_keys) > MAX_DIMENSIONS:
370+
raise SchemaValidationError(f"Maximum dimensions ({MAX_DIMENSIONS}) exceeded")
371+
372+
# Add default dimensions to this set
373+
with_defaults = dict(self.default_dimensions)
374+
with_defaults.update(sanitized)
375+
376+
self.dimension_sets.append(with_defaults)
377+
319378
def add_metadata(self, key: str, value: Any) -> None:
320379
"""Adds high cardinal metadata for metrics object
321380
@@ -377,6 +436,7 @@ def clear_metrics(self) -> None:
377436
logger.debug("Clearing out existing metric set from memory")
378437
self.metric_set.clear()
379438
self.dimension_set.clear()
439+
self.dimension_sets.clear()
380440
self.metadata_set.clear()
381441
self.set_default_dimensions(**self.default_dimensions)
382442

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""
2+
Tests for multiple dimension sets feature
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
9+
import pytest
10+
11+
from aws_lambda_powertools.metrics import Metrics, MetricUnit, SchemaValidationError
12+
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cloudwatch import AmazonCloudWatchEMFProvider
13+
14+
15+
def test_add_dimensions_creates_multiple_dimension_sets(capsys):
16+
# GIVEN a metrics instance
17+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
18+
19+
# WHEN we add multiple dimension sets
20+
metrics.add_dimension(name="service", value="booking")
21+
metrics.add_dimensions({"environment": "prod", "region": "us-east-1"})
22+
metrics.add_dimensions({"environment": "prod"})
23+
metrics.add_dimensions({"region": "us-east-1"})
24+
metrics.add_metric(name="SuccessfulRequests", unit=MetricUnit.Count, value=10)
25+
26+
# THEN the serialized output should contain multiple dimension arrays
27+
output = metrics.serialize_metric_set()
28+
29+
assert len(output["_aws"]["CloudWatchMetrics"]) == 1
30+
dimensions = output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]
31+
32+
# Should have 4 dimension sets: primary + 3 added
33+
assert len(dimensions) == 4
34+
assert dimensions[0] == ["service"] # Primary dimension set
35+
assert set(dimensions[1]) == {"environment", "region"}
36+
assert dimensions[2] == ["environment"]
37+
assert dimensions[3] == ["region"]
38+
39+
# All dimension values should be in the root
40+
assert output["service"] == "booking"
41+
assert output["environment"] == "prod"
42+
assert output["region"] == "us-east-1"
43+
assert output["SuccessfulRequests"] == [10.0]
44+
45+
46+
def test_add_dimensions_with_metrics_wrapper(capsys):
47+
# GIVEN a Metrics instance (not provider directly)
48+
metrics = Metrics(namespace="TestApp", service="payment")
49+
50+
# WHEN we use add_dimensions through the Metrics wrapper
51+
@metrics.log_metrics
52+
def handler(event, context):
53+
metrics.add_dimensions({"environment": "staging", "region": "us-west-2"})
54+
metrics.add_dimensions({"environment": "staging"})
55+
metrics.add_metric(name="PaymentProcessed", unit=MetricUnit.Count, value=1)
56+
57+
handler({}, {})
58+
59+
# THEN the output should contain multiple dimension sets
60+
output = json.loads(capsys.readouterr().out.strip())
61+
62+
dimensions = output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]
63+
assert len(dimensions) == 3 # Primary (service) + 2 added
64+
65+
# Primary dimension from service parameter
66+
assert "service" in dimensions[0]
67+
68+
# Check added dimension sets - they don't include service unless it's a default dimension
69+
assert set(dimensions[1]) == {"environment", "region"}
70+
assert set(dimensions[2]) == {"environment"}
71+
72+
73+
def test_add_dimensions_with_default_dimensions():
74+
# GIVEN metrics with default dimensions
75+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
76+
metrics.set_default_dimensions(tenant_id="123", application="api")
77+
78+
# WHEN we add dimension sets after setting defaults
79+
metrics.add_dimensions({"environment": "prod"})
80+
metrics.add_dimensions({"region": "eu-west-1"})
81+
metrics.add_metric(name="ApiCalls", unit=MetricUnit.Count, value=5)
82+
83+
# THEN default dimensions should be included in all dimension sets
84+
output = metrics.serialize_metric_set()
85+
dimensions = output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]
86+
87+
# Each dimension set should include default dimensions
88+
assert set(dimensions[1]) == {"tenant_id", "application", "environment"}
89+
assert set(dimensions[2]) == {"tenant_id", "application", "region"}
90+
91+
# Values should be in root
92+
assert output["tenant_id"] == "123"
93+
assert output["application"] == "api"
94+
assert output["environment"] == "prod"
95+
assert output["region"] == "eu-west-1"
96+
97+
98+
def test_add_dimensions_duplicate_keys_last_value_wins():
99+
# GIVEN metrics with overlapping dimension keys
100+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
101+
102+
# WHEN we add dimension sets with duplicate keys
103+
metrics.add_dimensions({"environment": "dev", "region": "us-east-1"})
104+
metrics.add_dimensions({"environment": "staging", "region": "us-west-2"})
105+
metrics.add_dimensions({"environment": "prod"}) # Last value for environment
106+
metrics.add_metric(name="TestMetric", unit=MetricUnit.Count, value=1)
107+
108+
# THEN the last value should be used in the root
109+
output = metrics.serialize_metric_set()
110+
111+
# Last values should win
112+
assert output["environment"] == "prod"
113+
assert output["region"] == "us-west-2"
114+
115+
116+
def test_add_dimensions_empty_dict_warns():
117+
# GIVEN metrics instance
118+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
119+
120+
# WHEN we add an empty dimensions dict
121+
with pytest.warns(UserWarning, match="Empty dimensions dictionary"):
122+
metrics.add_dimensions({})
123+
124+
# THEN no dimension set should be added
125+
assert len(metrics.dimension_sets) == 0
126+
127+
128+
def test_add_dimensions_invalid_dimensions_skipped():
129+
# GIVEN metrics instance
130+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
131+
132+
# WHEN we add dimensions with empty names or values
133+
with pytest.warns(UserWarning, match="empty name or value"):
134+
metrics.add_dimensions({"": "value", "key": ""})
135+
136+
# THEN no dimension set should be added
137+
assert len(metrics.dimension_sets) == 0
138+
139+
140+
def test_add_dimensions_exceeds_max_dimensions():
141+
# GIVEN metrics with many dimensions
142+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
143+
144+
# Add 29 dimensions to primary set (max is 30)
145+
for i in range(29):
146+
metrics.add_dimension(name=f"dim{i}", value=f"val{i}")
147+
148+
# WHEN we try to add dimension set that would exceed max
149+
# THEN it should raise SchemaValidationError
150+
with pytest.raises(SchemaValidationError, match="Maximum dimensions"):
151+
metrics.add_dimensions({"extra1": "val1", "extra2": "val2"})
152+
153+
154+
def test_add_dimensions_converts_values_to_strings():
155+
# GIVEN metrics instance
156+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
157+
158+
# WHEN we add dimensions with non-string values
159+
metrics.add_dimensions({"count": 123, "is_active": True, "ratio": 3.14})
160+
metrics.add_metric(name="TestMetric", unit=MetricUnit.Count, value=1)
161+
162+
# THEN values should be converted to strings
163+
output = metrics.serialize_metric_set()
164+
assert output["count"] == "123"
165+
assert output["is_active"] == "True"
166+
assert output["ratio"] == "3.14"
167+
168+
169+
def test_clear_metrics_clears_dimension_sets(capsys):
170+
# GIVEN metrics with dimension sets
171+
metrics = Metrics(namespace="TestApp", service="api")
172+
173+
@metrics.log_metrics
174+
def handler(event, context):
175+
metrics.add_dimensions({"environment": "prod"})
176+
metrics.add_dimensions({"region": "us-east-1"})
177+
metrics.add_metric(name="Requests", unit=MetricUnit.Count, value=1)
178+
179+
handler({}, {})
180+
181+
# WHEN we call clear_metrics (done automatically by decorator)
182+
# THEN dimension_sets should be cleared
183+
assert len(metrics.provider.dimension_sets) == 0
184+
185+
186+
def test_add_dimensions_order_preserved():
187+
# GIVEN metrics instance
188+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
189+
190+
# WHEN we add dimension sets in specific order
191+
metrics.add_dimension(name="service", value="api")
192+
metrics.add_dimensions({"environment": "prod", "region": "us-east-1"})
193+
metrics.add_dimensions({"environment": "prod"})
194+
metrics.add_dimensions({"region": "us-east-1"})
195+
metrics.add_metric(name="TestMetric", unit=MetricUnit.Count, value=1)
196+
197+
# THEN dimension sets should appear in order added
198+
output = metrics.serialize_metric_set()
199+
dimensions = output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]
200+
201+
assert dimensions[0] == ["service"]
202+
assert set(dimensions[1]) == {"environment", "region"}
203+
assert dimensions[2] == ["environment"]
204+
assert dimensions[3] == ["region"]
205+
206+
207+
def test_add_dimensions_with_metadata():
208+
# GIVEN metrics with metadata
209+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
210+
211+
# WHEN we add dimension sets and metadata
212+
metrics.add_dimensions({"environment": "prod"})
213+
metrics.add_metadata(key="request_id", value="abc-123")
214+
metrics.add_metric(name="ApiLatency", unit=MetricUnit.Milliseconds, value=150)
215+
216+
# THEN both should be in output
217+
output = metrics.serialize_metric_set()
218+
219+
assert "environment" in output
220+
assert output["request_id"] == "abc-123"
221+
# Primary dimension_set + 1 additional dimension set
222+
assert len(output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]) == 2
223+
224+
225+
def test_multiple_metrics_with_dimension_sets():
226+
# GIVEN metrics with multiple metrics and dimension sets
227+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
228+
229+
# WHEN we add multiple metrics with dimension sets
230+
metrics.add_dimensions({"environment": "prod", "region": "us-east-1"})
231+
metrics.add_dimensions({"environment": "prod"})
232+
metrics.add_metric(name="SuccessCount", unit=MetricUnit.Count, value=100)
233+
metrics.add_metric(name="ErrorCount", unit=MetricUnit.Count, value=5)
234+
metrics.add_metric(name="Latency", unit=MetricUnit.Milliseconds, value=250)
235+
236+
# THEN all metrics should share the same dimension sets
237+
output = metrics.serialize_metric_set()
238+
239+
assert len(output["_aws"]["CloudWatchMetrics"][0]["Metrics"]) == 3
240+
# Primary (empty) + 2 added dimension sets
241+
assert len(output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]) == 3
242+
assert output["SuccessCount"] == [100.0]
243+
assert output["ErrorCount"] == [5.0]
244+
assert output["Latency"] == [250.0]
245+
246+
247+
def test_add_dimensions_with_high_resolution_metrics():
248+
# GIVEN metrics with high resolution
249+
metrics = AmazonCloudWatchEMFProvider(namespace="TestApp")
250+
251+
# WHEN we add dimension sets with high-resolution metrics
252+
metrics.add_dimensions({"function": "process_order"})
253+
metrics.add_metric(
254+
name="ProcessingTime",
255+
unit=MetricUnit.Milliseconds,
256+
value=45,
257+
resolution=1, # High resolution
258+
)
259+
260+
# THEN dimension sets should work with high-resolution metrics
261+
output = metrics.serialize_metric_set()
262+
263+
# Primary (empty) + 1 added dimension set
264+
assert len(output["_aws"]["CloudWatchMetrics"][0]["Dimensions"]) == 2
265+
assert output["_aws"]["CloudWatchMetrics"][0]["Metrics"][0]["StorageResolution"] == 1

0 commit comments

Comments
 (0)