33from __future__ import annotations
44
55import logging
6+ from dataclasses import dataclass
67
78import boto3
89
9- from dsc .config import METRICS , METRICS_NAMESPACE
10+ from dsc .config import METRICS_NAMESPACE
1011
1112logger = logging .getLogger (__name__ )
1213
4344)
4445
4546
47+ @dataclass
48+ class Metric :
49+ """A class representing a single metric to be published to CloudWatch."""
50+
51+ name : str
52+ value : int
53+ unit : str
54+ dimensions : dict [str , str ] | None = None
55+
56+
4657class MetricsClient :
4758 """A simple client to record metrics to AWS CloudWatch."""
4859
49- def __init__ (self ) -> None :
60+ def __init__ (self , allowed_metrics : set [ str ] | None = None ) -> None :
5061 """Initialize the MetricsClient."""
51- self .cloudwatch = boto3 .client ("cloudwatch" )
52- self .batch_metrics : list [dict ] = []
62+ self .namespace = METRICS_NAMESPACE
63+ self .allowed_metrics : set [str ] | None = allowed_metrics
64+ self ._cloudwatch = boto3 .client ("cloudwatch" )
65+ self .batch_metrics : list [Metric ] = []
5366
5467 def publish_single_metric (
5568 self ,
56- metric_name : str ,
57- value : int ,
58- unit : str ,
59- metric_dimensions : dict [str , str ] | None = None ,
69+ metric : Metric ,
6070 ) -> None :
61- """Publish a single metric to CloudWatch.
62-
63- Args:
64- metric_name: The name of the metric to publish.
65- value: The value of the metric.
66- unit: The unit of the metric.
67- metric_dimensions: Optional dictionary of dimension names and values.
68-
69- Raises:
70- ValueError: If unit is invalid.
71- """
72- metric_data = self ._validate_and_build_metric_data (
73- metric_name , value , unit , metric_dimensions
74- )
75- self ._push_metric_data ([metric_data ])
71+ """Publish a single metric to CloudWatch."""
72+ self ._validate_metric (metric )
73+ self ._push_metric_data ([metric ])
7674
77- def _validate_and_build_metric_data (
75+ def _validate_metric (
7876 self ,
79- metric_name : str ,
80- value : int ,
81- unit : str ,
82- metric_dimensions : dict [str , str ] | None = None ,
83- ) -> dict :
84- """Validate and build a metric data dictionary for CloudWatch.
77+ metric : Metric ,
78+ ) -> bool :
79+ """Validate that a metric has required fields and allowed unit.
8580
8681 Args:
87- metric_name: The name of the metric.
88- value: The value of the metric.
89- unit: The unit of the metric.
90- metric_dimensions: Optional dictionary of dimension names and values.
91-
92- Returns:
93- A metric data dictionary formatted for CloudWatch.
82+ metric: The Metric instance to validate.
9483 """
95- self ._approved_metric (metric_name )
96- self ._validate_unit (unit )
97- dimensions = [
98- {"Name" : name , "Value" : dim_value }
99- for name , dim_value in (
100- metric_dimensions .items () if metric_dimensions else []
84+ if not all (hasattr (metric , attr ) for attr in ["name" , "value" , "unit" ]):
85+ raise ValueError (
86+ f"Metric must have 'name', 'value', and 'unit' attributes. Invalid "
87+ f"metric: { metric } "
10188 )
102- ]
103- return {
104- "MetricName" : metric_name ,
105- "Value" : value ,
106- "Unit" : unit ,
107- "Dimensions" : dimensions ,
108- }
89+ self ._allowed_metric (metric .name )
90+ self ._validate_metric_unit (metric .unit )
91+ return True
10992
110- def _approved_metric (self , metric_name : str ) -> bool :
111- """Check if a metric name is in the approved list of metrics for the application.
93+ def _allowed_metric (self , metric_name : str ) -> bool :
94+ """Check if a metric name is in the allowed list of metrics for the application.
11295
11396 Args:
11497 metric_name: The name of the metric to check.
11598 """
116- if metric_name not in METRICS :
99+ if self . allowed_metrics and metric_name not in self . allowed_metrics :
117100 raise ValueError (
118- f"Metric name '{ metric_name } ' is not in the approved list of metrics: "
119- f"{ ', ' .join (METRICS )} "
101+ f"Metric name '{ metric_name } ' is not in the allowed list of metrics: "
102+ f"{ ', ' .join (self . allowed_metrics )} "
120103 )
121104 return True
122105
123- def _validate_unit (self , unit : str ) -> None :
106+ def _validate_metric_unit (self , unit : str ) -> bool :
124107 """Validate that metric unit is allowed by AWS CloudWatch.
125108
126109 Args:
@@ -133,66 +116,75 @@ def _validate_unit(self, unit: str) -> None:
133116 raise ValueError (
134117 f"Invalid unit '{ unit } '. Must be one of: { ', ' .join (UNIT_VALUES )} "
135118 )
119+ return True
136120
137- def _push_metric_data (self , metric_data : list [dict ]) -> None :
138- """Push metric data to CloudWatch.
121+ def _push_metric_data (self , metrics : list [Metric ]) -> None :
122+ """Push metrics to CloudWatch.
139123
140124 Args:
141- metric_data : List of metric dictionaries to push.
125+ metrics : List of metric instances to push.
142126 """
127+ if not metrics :
128+ logger .info ("No metrics to publish." )
129+ return
130+
143131 try :
144- self .cloudwatch .put_metric_data (
145- Namespace = METRICS_NAMESPACE , MetricData = metric_data
132+ metric_data = []
133+ for metric in metrics :
134+ metric_dict = {
135+ "MetricName" : metric .name ,
136+ "Value" : metric .value ,
137+ "Unit" : metric .unit ,
138+ }
139+ if metric .dimensions :
140+ metric_dict ["Dimensions" ] = [
141+ {"Name" : key , "Value" : value }
142+ for key , value in metric .dimensions .items ()
143+ ]
144+ metric_data .append (metric_dict )
145+
146+ self ._cloudwatch .put_metric_data (
147+ Namespace = self .namespace ,
148+ MetricData = metric_data ,
149+ )
150+ logger .info (
151+ f"Published { len (metrics )} metric(s) to CloudWatch namespace "
152+ f"'{ self .namespace } '."
146153 )
147- logger .info (f"Published metric with { metric_data } to CloudWatch." )
148154 except Exception :
149155 logger .exception (
150- f"Failed to publish metric with { metric_data } to CloudWatch. "
156+ f"Failed to publish { len ( metrics ) } metric(s) to CloudWatch: "
151157 )
158+ raise
152159
153- def add_metric_to_batch (
154- self ,
155- metric_name : str ,
156- value : int ,
157- unit : str ,
158- metric_dimensions : dict [str , str ] | None = None ,
159- ) -> None :
160- """Add a metric to the batch for later publishing.
160+ def add_metric_to_batch (self , metric : Metric ) -> None :
161+ """Add a metric to the batch queue.
161162
162163 Args:
163- metric_name: The name of the metric.
164- value: The value of the metric.
165- unit: The unit of the metric.
166- metric_dimensions: Optional dictionary of dimension names and values.
167-
168- Raises:
169- ValueError: If unit is invalid.
164+ metric: The metric to add to the batch.
170165 """
171- metric_data = self ._validate_and_build_metric_data (
172- metric_name , value , unit , metric_dimensions
173- )
174- self .batch_metrics .append (metric_data )
166+ self ._validate_metric (metric )
167+ self .batch_metrics .append (metric )
175168
176169 def publish_batch_metrics (self , batch_size : int = 20 ) -> None :
177- """Publish all accumulated batch metrics to CloudWatch.
170+ """Publish a batch of metrics to CloudWatch.
178171
179- Raises:
180- ValueError: If any metric has an invalid unit or missing required fields.
172+ Clears the batch queue after publishing.
173+
174+ Args:
175+ batch_size: Number of metrics to publish in each batch.
181176 """
182177 if not self .batch_metrics :
183178 logger .info ("No metrics to publish." )
184179 return
185180
186- # Validate all metrics before publishing
187- for metric in self .batch_metrics :
188- if not all (key in metric for key in ["MetricName" , "Value" , "Unit" ]):
189- raise ValueError (
190- f"Each metric must contain 'MetricName', 'Value', and 'Unit'. "
191- f"Invalid metric: { metric } "
192- )
193- self ._approved_metric (metric ["MetricName" ])
194- self ._validate_unit (metric ["Unit" ])
195-
196- for x in range (0 , len (self .batch_metrics ), batch_size ):
197- self ._push_metric_data (self .batch_metrics [x : x + batch_size ])
198- self .batch_metrics .clear ()
181+ try :
182+ # Re-validate all metrics before publishing to CloudWatch
183+ for metric in self .batch_metrics :
184+ self ._validate_metric (metric )
185+
186+ for x in range (0 , len (self .batch_metrics ), batch_size ):
187+ batch = self .batch_metrics [x : x + batch_size ]
188+ self ._push_metric_data (batch )
189+ finally :
190+ self .batch_metrics .clear ()
0 commit comments