Skip to content

Commit 132159c

Browse files
author
Mark Kuhn
authored
Add dimension, metric and namespace validation (#91)
* add dimension, metric and namespace validation * update readme with validation errors
1 parent c3ea4ed commit 132159c

File tree

10 files changed

+219
-26
lines changed

10 files changed

+219
-26
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Requirements:
6262
- Name Length 1-255 characters
6363
- Name must be ASCII characters only
6464
- 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.
65-
- Units must meet CW Metrics unit requirements, if not it will default to None. See [MetricDatum](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) for valid values.
65+
- 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.
6666

6767
Examples:
6868

@@ -102,6 +102,7 @@ Requirements:
102102

103103
- Length 1-255 characters
104104
- ASCII characters only
105+
- Dimensions must meet CloudWatch Dimensions requirements, otherwise a `InvalidDimensionError` or `DimensionSetExceededError` will be thrown. See [Dimensions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_Dimension.html) for valid values.
105106

106107
Examples:
107108

@@ -122,6 +123,7 @@ Requirements:
122123

123124
- Length 1-255 characters
124125
- ASCII characters only
126+
- Dimensions must meet CloudWatch Dimensions requirements, otherwise a `InvalidDimensionError` or `DimensionSetExceededError` will be thrown. See [Dimensions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_Dimension.html) for valid values.
125127

126128
Examples:
127129

@@ -157,6 +159,7 @@ Requirements:
157159

158160
- Name Length 1-255 characters
159161
- Name must be ASCII characters only
162+
- Namespace must meet CloudWatch Namespace requirements, otherwise a `InvalidNamespaceError` will be thrown. See [Namespaces](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#Namespace) for valid values.
160163

161164
Examples:
162165

aws_embedded_metrics/constants.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
# limitations under the License.
1313

1414
DEFAULT_NAMESPACE = "aws-embedded-metrics"
15-
MAX_DIMENSION_SET_SIZE = 30
1615
MAX_METRICS_PER_EVENT = 100
1716
MAX_DATAPOINTS_PER_METRIC = 100
17+
MAX_DIMENSION_SET_SIZE = 30
18+
MAX_DIMENSION_NAME_LENGTH = 250
19+
MAX_DIMENSION_VALUE_LENGTH = 1024
20+
MAX_METRIC_NAME_LENGTH = 1024
21+
MAX_NAMESPACE_LENGTH = 256
22+
VALID_NAMESPACE_REGEX = '^[a-zA-Z0-9._#:/-]+$'

aws_embedded_metrics/exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,21 @@ class DimensionSetExceededError(Exception):
1515
def __init__(self, message: str) -> None:
1616
# Call the base class constructor with the parameters it needs
1717
super().__init__(message)
18+
19+
20+
class InvalidDimensionError(Exception):
21+
def __init__(self, message: str) -> None:
22+
# Call the base class constructor with the parameters it needs
23+
super().__init__(message)
24+
25+
26+
class InvalidMetricError(Exception):
27+
def __init__(self, message: str) -> None:
28+
# Call the base class constructor with the parameters it needs
29+
super().__init__(message)
30+
31+
32+
class InvalidNamespaceError(Exception):
33+
def __init__(self, message: str) -> None:
34+
# Call the base class constructor with the parameters it needs
35+
super().__init__(message)

aws_embedded_metrics/logger/metrics_context.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414

1515
from aws_embedded_metrics import constants, utils
1616
from aws_embedded_metrics.config import get_config
17-
from aws_embedded_metrics.constants import MAX_DIMENSION_SET_SIZE
18-
from aws_embedded_metrics.exceptions import DimensionSetExceededError
1917
from aws_embedded_metrics.logger.metric import Metric
18+
from aws_embedded_metrics.validator import validate_dimension_set, validate_metric
2019
from typing import List, Dict, Any, Set
2120

2221

@@ -50,22 +49,14 @@ def put_metric(self, key: str, value: float, unit: str = None) -> None:
5049
context.put_metric("Latency", 100, "Milliseconds")
5150
```
5251
"""
52+
validate_metric(key, value, unit)
5353
metric = self.metrics.get(key)
5454
if metric:
5555
# TODO: we should log a warning if the unit has been changed
5656
metric.add_value(value)
5757
else:
5858
self.metrics[key] = Metric(value, unit)
5959

60-
@staticmethod
61-
def validate_dimension_set(dimensions: Dict[str, str]) -> None:
62-
"""
63-
Validates dimension set length is not more than MAX_DIMENSION_SET_SIZE
64-
"""
65-
if len(dimensions) > MAX_DIMENSION_SET_SIZE:
66-
raise DimensionSetExceededError(
67-
f"Maximum number of dimensions per dimension set allowed are {MAX_DIMENSION_SET_SIZE}")
68-
6960
def put_dimensions(self, dimension_set: Dict[str, str]) -> None:
7061
"""
7162
Adds dimensions to the context.
@@ -77,7 +68,7 @@ def put_dimensions(self, dimension_set: Dict[str, str]) -> None:
7768
# TODO add ability to define failure strategy
7869
return
7970

80-
self.validate_dimension_set(dimension_set)
71+
validate_dimension_set(dimension_set)
8172

8273
# Duplicate dimension sets are removed before being added to the end of the collection.
8374
# This ensures only latest dimension value is used as a target member on the root EMF node.
@@ -99,7 +90,7 @@ def set_dimensions(self, dimension_sets: List[Dict[str, str]], use_default: bool
9990
self.should_use_default_dimensions = use_default
10091

10192
for dimension_set in dimension_sets:
102-
self.validate_dimension_set(dimension_set)
93+
validate_dimension_set(dimension_set)
10394

10495
self.dimensions = dimension_sets
10596

aws_embedded_metrics/logger/metrics_logger.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from aws_embedded_metrics.environment import Environment
1515
from aws_embedded_metrics.logger.metrics_context import MetricsContext
16+
from aws_embedded_metrics.validator import validate_namespace
1617
from aws_embedded_metrics.config import get_config
1718
from typing import Any, Awaitable, Callable, Dict, Tuple
1819
import sys
@@ -74,6 +75,7 @@ def reset_dimensions(self, use_default: bool) -> "MetricsLogger":
7475
return self
7576

7677
def set_namespace(self, namespace: str) -> "MetricsLogger":
78+
validate_namespace(namespace)
7779
self.context.namespace = namespace
7880
return self
7981

aws_embedded_metrics/unit.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
from enum import Enum
1+
from enum import Enum, EnumMeta
22

33

4-
class Unit(Enum):
4+
class UnitMeta(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 Unit(Enum, metaclass=UnitMeta):
515
SECONDS = "Seconds"
616
MICROSECONDS = "Microseconds"
717
MILLISECONDS = "Milliseconds"
@@ -28,3 +38,4 @@ class Unit(Enum):
2838
GIGABITS_PER_SECOND = "Gigabits/Second"
2939
TERABITS_PER_SECOND = "Terabits/Second"
3040
COUNT_PER_SECOND = "Count/Second"
41+
NONE = "None"

aws_embedded_metrics/validator.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2019 Amazon.com, Inc. or its affiliates.
2+
# Licensed under the Apache License, Version 2.0 (the
3+
# "License"); you may not use this file except in compliance
4+
# with the License. You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
import math
15+
import re
16+
from typing import Dict, Optional
17+
from aws_embedded_metrics.unit import Unit
18+
from aws_embedded_metrics.exceptions import DimensionSetExceededError, InvalidDimensionError, InvalidMetricError, InvalidNamespaceError
19+
import aws_embedded_metrics.constants as constants
20+
21+
22+
def validate_dimension_set(dimension_set: Dict[str, str]) -> None:
23+
"""
24+
Validates a dimension set
25+
26+
Parameters:
27+
dimension_set (Dict[str, str]): The dimension set to validate
28+
29+
Raises:
30+
DimensionSetExceededError: If the dimension set is too large
31+
InvalidDimensionError: If a dimension is invalid
32+
"""
33+
if len(dimension_set) > constants.MAX_DIMENSION_SET_SIZE:
34+
raise DimensionSetExceededError(
35+
f"Maximum number of dimensions per dimension set allowed are {constants.MAX_DIMENSION_SET_SIZE}")
36+
37+
for name, value in dimension_set.items():
38+
if not name or len(name.strip()) == 0:
39+
raise InvalidDimensionError("Dimension name must include at least one non-whitespace character")
40+
41+
if not value or len(value.strip()) == 0:
42+
raise InvalidDimensionError("Dimension value must include at least one non-whitespace character")
43+
44+
if len(name) > constants.MAX_DIMENSION_NAME_LENGTH:
45+
raise InvalidDimensionError(f"Dimension name cannot be longer than {constants.MAX_DIMENSION_NAME_LENGTH} characters")
46+
47+
if len(value) > constants.MAX_DIMENSION_VALUE_LENGTH:
48+
raise InvalidDimensionError(f"Dimension value cannot be longer than {constants.MAX_DIMENSION_VALUE_LENGTH} characters")
49+
50+
if not name.isascii():
51+
raise InvalidDimensionError(f"Dimension name contains invalid characters: {name}")
52+
53+
if not value.isascii():
54+
raise InvalidDimensionError(f"Dimension value contains invalid characters: {value}")
55+
56+
if name.startswith(":"):
57+
raise InvalidDimensionError("Dimension name cannot start with ':'")
58+
59+
60+
def validate_metric(name: str, value: float, unit: Optional[str]) -> None:
61+
"""
62+
Validates a metric
63+
64+
Parameters:
65+
name (str): The name of the metric
66+
value (float): The value of the metric
67+
unit (Optional[str]): The unit of the metric
68+
69+
Raises:
70+
InvalidMetricError: If the metric is invalid
71+
"""
72+
if not name or len(name.strip()) == 0:
73+
raise InvalidMetricError("Metric name must include at least one non-whitespace character")
74+
75+
if len(name) > constants.MAX_DIMENSION_NAME_LENGTH:
76+
raise InvalidMetricError(f"Metric name cannot be longer than {constants.MAX_DIMENSION_NAME_LENGTH} characters")
77+
78+
if not math.isfinite(value):
79+
raise InvalidMetricError("Metric value must be finite")
80+
81+
if unit is not None and unit not in Unit:
82+
raise InvalidMetricError(f"Metric unit is not valid: {unit}")
83+
84+
85+
def validate_namespace(namespace: str) -> None:
86+
"""
87+
Validates a namespace
88+
89+
Parameters:
90+
namespace (str): The namespace to validate
91+
92+
Raises:
93+
InvalidNamespaceError: If the namespace is invalid
94+
"""
95+
if not namespace or len(namespace.strip()) == 0:
96+
raise InvalidNamespaceError("Namespace must include at least one non-whitespace character")
97+
98+
if len(namespace) > constants.MAX_NAMESPACE_LENGTH:
99+
raise InvalidNamespaceError(f"Namespace cannot be longer than {constants.MAX_NAMESPACE_LENGTH} characters")
100+
101+
if not re.match(constants.VALID_NAMESPACE_REGEX, namespace):
102+
raise InvalidNamespaceError(f"Namespace contains invalid characters: {namespace}")

tests/logger/test_metrics_context.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import pytest
2+
import math
3+
import random
4+
from aws_embedded_metrics import constants
5+
from aws_embedded_metrics.unit import Unit
16
from aws_embedded_metrics import config
27
from aws_embedded_metrics.logger.metrics_context import MetricsContext
38
from aws_embedded_metrics.constants import DEFAULT_NAMESPACE
4-
from aws_embedded_metrics.exceptions import DimensionSetExceededError
9+
from aws_embedded_metrics.exceptions import DimensionSetExceededError, InvalidDimensionError, InvalidMetricError
510
from importlib import reload
611
from faker import Faker
7-
import pytest
812

913
fake = Faker()
1014

@@ -228,12 +232,37 @@ def test_get_dimensions_returns_merged_custom_and_default_dimensions():
228232
assert [expected_dimensions] == actual_dimensions
229233

230234

235+
@pytest.mark.parametrize(
236+
"name, value",
237+
[
238+
(None, "value"),
239+
("", "value"),
240+
(" ", "value"),
241+
("a" * (constants.MAX_DIMENSION_NAME_LENGTH + 1), "value"),
242+
("ḓɨɱɛɳʂɨøɳ", "value"),
243+
(":dim", "value"),
244+
("dim", ""),
245+
("dim", " "),
246+
("dim", "a" * (constants.MAX_DIMENSION_VALUE_LENGTH + 1)),
247+
("dim", "ṽɑɭʊɛ"),
248+
]
249+
)
250+
def test_add_invalid_dimensions_raises_exception(name, value):
251+
context = MetricsContext()
252+
253+
with pytest.raises(InvalidDimensionError):
254+
context.put_dimensions({name: value})
255+
256+
with pytest.raises(InvalidDimensionError):
257+
context.set_dimensions([{name: value}])
258+
259+
231260
def test_put_metric_adds_metrics():
232261
# arrange
233262
context = MetricsContext()
234263
metric_key = fake.word()
235264
metric_value = fake.random.random()
236-
metric_unit = fake.word()
265+
metric_unit = random.choice(list(Unit)).value
237266

238267
# act
239268
context.put_metric(metric_key, metric_value, metric_unit)
@@ -258,6 +287,28 @@ def test_put_metric_uses_none_unit_if_not_provided():
258287
assert metric.unit == "None"
259288

260289

290+
@pytest.mark.parametrize(
291+
"name, value, unit",
292+
[
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")
303+
]
304+
)
305+
def test_put_invalid_metric_raises_exception(name, value, unit):
306+
context = MetricsContext()
307+
308+
with pytest.raises(InvalidMetricError):
309+
context.put_metric(name, value, unit)
310+
311+
261312
def test_create_copy_with_context_creates_new_instance():
262313
# arrange
263314
context = MetricsContext()
@@ -340,10 +391,10 @@ def test_create_copy_with_context_does_not_copy_metrics():
340391
def test_set_dimensions_overwrites_all_dimensions():
341392
# arrange
342393
context = MetricsContext()
343-
context.set_default_dimensions({fake.word(): fake.word})
344-
context.put_dimensions({fake.word(): fake.word})
394+
context.set_default_dimensions({fake.word(): fake.word()})
395+
context.put_dimensions({fake.word(): fake.word()})
345396

346-
expected_dimensions = [{fake.word(): fake.word}]
397+
expected_dimensions = [{fake.word(): fake.word()}]
347398

348399
# act
349400
context.set_dimensions(expected_dimensions)

tests/logger/test_metrics_logger.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +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
6+
import aws_embedded_metrics.constants as constants
57
import pytest
68
from faker import Faker
79
from asyncio import Future
@@ -353,6 +355,14 @@ async def test_can_set_namespace(mocker):
353355
assert context.namespace == expected_value
354356

355357

358+
@pytest.mark.parametrize("namespace", [None, "", " ", "a" * (constants.MAX_NAMESPACE_LENGTH + 1), "ŋàɱȅƨƥȁƈȅ", "namespace "])
359+
def test_set_invalid_namespace_throws_exception(namespace, mocker):
360+
logger, sink, env = get_logger_and_sink(mocker)
361+
362+
with pytest.raises(InvalidNamespaceError):
363+
logger.set_namespace(namespace)
364+
365+
356366
@pytest.mark.asyncio
357367
async def test_context_is_preserved_across_flushes(mocker):
358368
# arrange

0 commit comments

Comments
 (0)