Skip to content

Commit c7c02c4

Browse files
authored
High Resolution Metrics Support (awslabs#96)
1 parent a78d1b9 commit c7c02c4

File tree

13 files changed

+194
-27
lines changed

13 files changed

+194
-27
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ from aws_embedded_metrics import metric_scope
3939
@metric_scope
4040
def my_handler(metrics):
4141
metrics.put_dimensions({"Foo": "Bar"})
42-
metrics.put_metric("ProcessingLatency", 100, "Milliseconds")
42+
metrics.put_metric("ProcessingLatency", 100, "Milliseconds", StorageResolution.STANDARD)
43+
metrics.put_metric("Memory.HeapUsed", 1600424.0, "Bytes", StorageResolution.HIGH)
4344
metrics.set_property("AccountId", "123456789012")
4445
metrics.set_property("RequestId", "422b1569-16f6-4a03")
4546
metrics.set_property("DeviceId", "61270781-c6ac-46f1")
@@ -53,9 +54,9 @@ def my_handler(metrics):
5354

5455
The `MetricsLogger` is the interface you will use to publish embedded metrics.
5556

56-
- **put_metric**(key: str, value: float, unit: str = "None") -> MetricsLogger
57+
- **put_metric**(key: str, value: float, unit: str = "None", storage_resolution: int = 60) -> MetricsLogger
5758

58-
Adds a new metric to the current logger context. Multiple metrics using the same key will be appended to an array of values. The Embedded Metric Format supports a maximum of 100 values per key. If more metric values are added than are supported by the format, the logger will be flushed to allow for new metric values to be captured.
59+
Adds a new metric to the current logger context. Multiple metrics using the same key will be appended to an array of values. Multiple metrics cannot have same key and different storage resolution. The Embedded Metric Format supports a maximum of 100 values per key. If more metric values are added than are supported by the format, the logger will be flushed to allow for new metric values to be captured.
5960

6061
Requirements:
6162

@@ -64,10 +65,18 @@ Requirements:
6465
- Values must be in the range of 8.515920e-109 to 1.174271e+108. In addition, special values (for example, NaN, +Infinity, -Infinity) are not supported.
6566
- Metrics must meet CloudWatch Metrics requirements, otherwise a `InvalidMetricError` will be thrown. See [MetricDatum](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) for valid values.
6667

68+
- ##### Storage Resolution
69+
An OPTIONAL value representing the storage resolution for the corresponding metric. Setting this to `High` specifies this metric as a high-resolution metric, so that CloudWatch stores the metric with sub-minute resolution down to one second. Setting this to `Standard` specifies this metric as a standard-resolution metric, which CloudWatch stores at 1-minute resolution. If a value is not provided, then a default value of `Standard` is assumed. See [Cloud Watch High-Resolution metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#high-resolution-metrics)
70+
6771
Examples:
6872

6973
```py
74+
# Standard Resolution example
7075
put_metric("Latency", 200, "Milliseconds")
76+
put_metric("Latency", 201, "Milliseconds", StorageResolution.STANDARD)
77+
78+
# High Resolution example
79+
put_metric("Memory.HeapUsed", 1600424.0, "Bytes", StorageResolution.HIGH)
7180
```
7281

7382
- **set_property**(key: str, value: Any) -> MetricsLogger

aws_embedded_metrics/logger/metric.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
13+
from aws_embedded_metrics.storage_resolution import StorageResolution
1314

1415

1516
class Metric(object):
16-
def __init__(self, value: float, unit: str = None):
17+
def __init__(self, value: float, unit: str = None, storage_resolution: StorageResolution = StorageResolution.STANDARD):
1718
self.values = [value]
1819
self.unit = unit or "None"
20+
self.storage_resolution = storage_resolution or StorageResolution.STANDARD
1921

2022
def add_value(self, value: float) -> None:
2123
self.values.append(value)

aws_embedded_metrics/logger/metrics_context.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from aws_embedded_metrics.config import get_config
1717
from aws_embedded_metrics.logger.metric import Metric
1818
from aws_embedded_metrics.validator import validate_dimension_set, validate_metric
19+
from aws_embedded_metrics.storage_resolution import StorageResolution
1920
from typing import List, Dict, Any, Set
2021

2122

@@ -39,8 +40,9 @@ def __init__(
3940
self.metrics: Dict[str, Metric] = {}
4041
self.should_use_default_dimensions = True
4142
self.meta: Dict[str, Any] = {"Timestamp": utils.now()}
43+
self.metric_name_and_resolution_map: Dict[str, StorageResolution] = {}
4244

43-
def put_metric(self, key: str, value: float, unit: str = None) -> None:
45+
def put_metric(self, key: str, value: float, unit: str = None, storage_resolution: StorageResolution = StorageResolution.STANDARD) -> None:
4446
"""
4547
Adds a metric measurement to the context.
4648
Multiple calls using the same key will be stored as an
@@ -49,13 +51,14 @@ def put_metric(self, key: str, value: float, unit: str = None) -> None:
4951
context.put_metric("Latency", 100, "Milliseconds")
5052
```
5153
"""
52-
validate_metric(key, value, unit)
54+
validate_metric(key, value, unit, storage_resolution, self.metric_name_and_resolution_map)
5355
metric = self.metrics.get(key)
5456
if metric:
5557
# TODO: we should log a warning if the unit has been changed
5658
metric.add_value(value)
5759
else:
58-
self.metrics[key] = Metric(value, unit)
60+
self.metrics[key] = Metric(value, unit, storage_resolution)
61+
self.metric_name_and_resolution_map[key] = storage_resolution
5962

6063
def put_dimensions(self, dimension_set: Dict[str, str]) -> None:
6164
"""

aws_embedded_metrics/logger/metrics_logger.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from aws_embedded_metrics.logger.metrics_context import MetricsContext
1616
from aws_embedded_metrics.validator import validate_namespace
1717
from aws_embedded_metrics.config import get_config
18+
from aws_embedded_metrics.storage_resolution import StorageResolution
1819
from typing import Any, Awaitable, Callable, Dict, Tuple
1920
import sys
2021
import traceback
@@ -78,8 +79,10 @@ def set_namespace(self, namespace: str) -> "MetricsLogger":
7879
self.context.namespace = namespace
7980
return self
8081

81-
def put_metric(self, key: str, value: float, unit: str = "None") -> "MetricsLogger":
82-
self.context.put_metric(key, value, unit)
82+
def put_metric(
83+
self, key: str, value: float, unit: str = "None", storage_resolution: StorageResolution = StorageResolution.STANDARD
84+
) -> "MetricsLogger":
85+
self.context.put_metric(key, value, unit, storage_resolution)
8386
return self
8487

8588
def add_stack_trace(self, key: str, details: Any = None, exc_info: Tuple = None) -> "MetricsLogger":

aws_embedded_metrics/serializers/log_serializer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
MAX_DIMENSION_SET_SIZE, MAX_METRICS_PER_EVENT, MAX_DATAPOINTS_PER_METRIC
1919
)
2020
from aws_embedded_metrics.exceptions import DimensionSetExceededError
21+
from aws_embedded_metrics.storage_resolution import StorageResolution
2122
import json
2223
from typing import Any, Dict, List
2324

@@ -87,8 +88,11 @@ def create_body() -> Dict[str, Any]:
8788
if len(metric.values) > end_index:
8889
remaining_data = True
8990

91+
metric_body = {"Name": metric_name, "Unit": metric.unit}
92+
if metric.storage_resolution == StorageResolution.HIGH:
93+
metric_body["StorageResolution"] = metric.storage_resolution.value # type: ignore
9094
if not config.disable_metric_extraction:
91-
current_body["_aws"]["CloudWatchMetrics"][0]["Metrics"].append({"Name": metric_name, "Unit": metric.unit})
95+
current_body["_aws"]["CloudWatchMetrics"][0]["Metrics"].append(metric_body)
9296
num_metrics_in_current_body += 1
9397

9498
if (num_metrics_in_current_body == MAX_METRICS_PER_EVENT):
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from enum import Enum, EnumMeta
2+
3+
4+
class StorageResolutionMeta(EnumMeta):
5+
def __contains__(self, item: object) -> bool:
6+
try:
7+
self(item)
8+
except (ValueError, TypeError):
9+
return False
10+
else:
11+
return True
12+
13+
14+
class StorageResolution(Enum, metaclass=StorageResolutionMeta):
15+
STANDARD = 60
16+
HIGH = 1

aws_embedded_metrics/validator.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import re
1616
from typing import Dict, Optional
1717
from aws_embedded_metrics.unit import Unit
18+
from aws_embedded_metrics.storage_resolution import StorageResolution
1819
from aws_embedded_metrics.exceptions import DimensionSetExceededError, InvalidDimensionError, InvalidMetricError, InvalidNamespaceError
1920
import aws_embedded_metrics.constants as constants
2021

@@ -57,14 +58,20 @@ def validate_dimension_set(dimension_set: Dict[str, str]) -> None:
5758
raise InvalidDimensionError("Dimension name cannot start with ':'")
5859

5960

60-
def validate_metric(name: str, value: float, unit: Optional[str]) -> None:
61+
def validate_metric(name: str,
62+
value: float,
63+
unit: Optional[str],
64+
storage_resolution: StorageResolution,
65+
metric_name_and_resolution_map: dict) -> None:
6166
"""
6267
Validates a metric
6368
6469
Parameters:
6570
name (str): The name of the metric
6671
value (float): The value of the metric
6772
unit (Optional[str]): The unit of the metric
73+
storage_resolution (Optional[int]): The storage resolution of metric
74+
metric_name_and_resolution_map (dict): The map used for validating metric
6875
6976
Raises:
7077
InvalidMetricError: If the metric is invalid
@@ -81,6 +88,13 @@ def validate_metric(name: str, value: float, unit: Optional[str]) -> None:
8188
if unit is not None and unit not in Unit:
8289
raise InvalidMetricError(f"Metric unit is not valid: {unit}")
8390

91+
if storage_resolution is None or storage_resolution not in StorageResolution:
92+
raise InvalidMetricError(f"Metric storage resolution is not valid: {storage_resolution}")
93+
94+
if name in metric_name_and_resolution_map and metric_name_and_resolution_map.get(name) is not storage_resolution:
95+
raise InvalidMetricError(
96+
f"Resolution for metrics {name} is already set. A single log event cannot have a metric with two different resolutions.")
97+
8498

8599
def validate_namespace(namespace: str) -> None:
86100
"""

examples/ec2/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from aws_embedded_metrics import metric_scope
2+
from aws_embedded_metrics.storage_resolution import StorageResolution
23

34
import logging
45

@@ -10,6 +11,7 @@
1011
def my_handler(metrics):
1112
metrics.put_dimensions({"Foo": "Bar"})
1213
metrics.put_metric("ProcessingLatency", 100, "Milliseconds")
14+
metrics.put_metric("CPU Utilization", 87, "Percent", StorageResolution.HIGH)
1315
metrics.set_property("AccountId", "123456789012")
1416
metrics.set_property("RequestId", "422b1569-16f6-4a03-b8f0-fe3fd9b100f8")
1517
metrics.set_property("DeviceId", "61270781-c6ac-46f1-baf7-22c808af8162")

examples/lambda/function.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from aws_embedded_metrics import metric_scope
2+
from aws_embedded_metrics.storage_resolution import StorageResolution
23

34

45
@metric_scope
56
def my_handler(event, context, metrics):
67
metrics.put_dimensions({"Foo": "Bar"})
78
metrics.put_metric("ProcessingLatency", 100, "Milliseconds")
9+
metrics.put_metric("CPU Utilization", 87, "Percent", StorageResolution.HIGH)
810
metrics.set_property("AccountId", "123456789012")
911
metrics.set_property("RequestId", "422b1569-16f6-4a03-b8f0-fe3fd9b100f8")
1012
metrics.set_property("DeviceId", "61270781-c6ac-46f1-baf7-22c808af8162")

tests/canary/agent/canary.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import aws_embedded_metrics
33
from aws_embedded_metrics import metric_scope
44
from aws_embedded_metrics.config import get_config
5+
from aws_embedded_metrics.storage_resolution import StorageResolution
56
from getversion import get_module_version
67
import os
78
import psutil
@@ -26,7 +27,7 @@ async def app(init, last_run_duration, metrics):
2627
metrics.set_dimensions({"Runtime": 'Python', "Platform": 'ECS', "Agent": 'CloudWatchAgent', "Version": version})
2728
metrics.put_metric('Invoke', 1, "Count")
2829
metrics.put_metric('Duration', last_run_duration, 'Seconds')
29-
metrics.put_metric('Memory.RSS', process.memory_info().rss, 'Bytes')
30+
metrics.put_metric('Memory.RSS', process.memory_info().rss, 'Bytes', StorageResolution.HIGH)
3031

3132

3233
async def main():

tests/logger/test_metrics_context.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import random
44
from aws_embedded_metrics import constants
55
from aws_embedded_metrics.unit import Unit
6+
from aws_embedded_metrics.storage_resolution import StorageResolution
67
from aws_embedded_metrics import config
78
from aws_embedded_metrics.logger.metrics_context import MetricsContext
89
from aws_embedded_metrics.constants import DEFAULT_NAMESPACE
@@ -263,14 +264,16 @@ def test_put_metric_adds_metrics():
263264
metric_key = fake.word()
264265
metric_value = fake.random.random()
265266
metric_unit = random.choice(list(Unit)).value
267+
metric_storage_resolution = random.choice(list(StorageResolution)).value
266268

267269
# act
268-
context.put_metric(metric_key, metric_value, metric_unit)
270+
context.put_metric(metric_key, metric_value, metric_unit, metric_storage_resolution)
269271

270272
# assert
271273
metric = context.metrics[metric_key]
272274
assert metric.unit == metric_unit
273275
assert metric.values == [metric_value]
276+
assert metric.storage_resolution == metric_storage_resolution
274277

275278

276279
def test_put_metric_uses_none_unit_if_not_provided():
@@ -287,26 +290,43 @@ def test_put_metric_uses_none_unit_if_not_provided():
287290
assert metric.unit == "None"
288291

289292

293+
def test_put_metric_uses_standard_storage_resolution_if_not_provided():
294+
# arrange
295+
context = MetricsContext()
296+
metric_key = fake.word()
297+
metric_value = fake.random.random()
298+
299+
# act
300+
context.put_metric(metric_key, metric_value)
301+
302+
# assert
303+
metric = context.metrics[metric_key]
304+
assert metric.storage_resolution == StorageResolution.STANDARD
305+
306+
290307
@pytest.mark.parametrize(
291-
"name, value, unit",
308+
"name, value, unit, storage_resolution",
292309
[
293-
("", 1, "None"),
294-
(" ", 1, "Seconds"),
295-
("a" * (constants.MAX_METRIC_NAME_LENGTH + 1), 1, "None"),
296-
("metric", float("inf"), "Count"),
297-
("metric", float("-inf"), "Count"),
298-
("metric", float("nan"), "Count"),
299-
("metric", math.inf, "Seconds"),
300-
("metric", -math.inf, "Seconds"),
301-
("metric", math.nan, "Seconds"),
302-
("metric", 1, "Kilometers/Fahrenheit")
310+
("", 1, "None", StorageResolution.STANDARD),
311+
(" ", 1, "Seconds", StorageResolution.STANDARD),
312+
("a" * (constants.MAX_METRIC_NAME_LENGTH + 1), 1, "None", StorageResolution.STANDARD),
313+
("metric", float("inf"), "Count", StorageResolution.STANDARD),
314+
("metric", float("-inf"), "Count", StorageResolution.STANDARD),
315+
("metric", float("nan"), "Count", StorageResolution.STANDARD),
316+
("metric", math.inf, "Seconds", StorageResolution.STANDARD),
317+
("metric", -math.inf, "Seconds", StorageResolution.STANDARD),
318+
("metric", math.nan, "Seconds", StorageResolution.STANDARD),
319+
("metric", 1, "Kilometers/Fahrenheit", StorageResolution.STANDARD),
320+
("metric", 1, "Seconds", 2),
321+
("metric", 1, "Seconds", 0),
322+
("metric", 1, "Seconds", None)
303323
]
304324
)
305-
def test_put_invalid_metric_raises_exception(name, value, unit):
325+
def test_put_invalid_metric_raises_exception(name, value, unit, storage_resolution):
306326
context = MetricsContext()
307327

308328
with pytest.raises(InvalidMetricError):
309-
context.put_metric(name, value, unit)
329+
context.put_metric(name, value, unit, storage_resolution)
310330

311331

312332
def test_create_copy_with_context_creates_new_instance():

tests/logger/test_metrics_logger.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from aws_embedded_metrics.logger import metrics_logger
33
from aws_embedded_metrics.sinks import Sink
44
from aws_embedded_metrics.environment import Environment
5-
from aws_embedded_metrics.exceptions import InvalidNamespaceError
5+
from aws_embedded_metrics.exceptions import InvalidNamespaceError, InvalidMetricError
6+
from aws_embedded_metrics.storage_resolution import StorageResolution
67
import aws_embedded_metrics.constants as constants
78
import pytest
89
from faker import Faker
@@ -53,6 +54,49 @@ async def test_can_put_metric(mocker):
5354
assert context.metrics[expected_key].unit == "None"
5455

5556

57+
@pytest.mark.asyncio
58+
async def test_can_put_metric_with_different_storage_resolution_different_flush(mocker):
59+
# arrange
60+
expected_key = fake.word()
61+
expected_value = fake.random.randrange(100)
62+
63+
logger, sink, env = get_logger_and_sink(mocker)
64+
65+
# act
66+
logger.put_metric(expected_key, expected_value, None, StorageResolution.HIGH)
67+
await logger.flush()
68+
69+
# assert
70+
context = sink.accept.call_args[0][0]
71+
assert context.metrics[expected_key].values == [expected_value]
72+
assert context.metrics[expected_key].unit == "None"
73+
assert context.metrics[expected_key].storage_resolution == StorageResolution.HIGH
74+
75+
expected_key = fake.word()
76+
expected_value = fake.random.randrange(100)
77+
logger.put_metric(expected_key, expected_value, None)
78+
await logger.flush()
79+
context = sink.accept.call_args[0][0]
80+
assert context.metrics[expected_key].values == [expected_value]
81+
assert context.metrics[expected_key].unit == "None"
82+
assert context.metrics[expected_key].storage_resolution == StorageResolution.STANDARD
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_cannot_put_metric_with_different_storage_resolution_same_flush(mocker):
87+
# arrange
88+
expected_key = fake.word()
89+
expected_value = fake.random.randrange(100)
90+
91+
logger, sink, env = get_logger_and_sink(mocker)
92+
93+
# act
94+
logger.put_metric(expected_key, expected_value, None, StorageResolution.HIGH)
95+
with pytest.raises(InvalidMetricError):
96+
logger.put_metric(expected_key, expected_value, None, StorageResolution.STANDARD)
97+
await logger.flush()
98+
99+
56100
@pytest.mark.asyncio
57101
async def test_can_add_stack_trace(mocker):
58102
# arrange

0 commit comments

Comments
 (0)