Coverage for mcp/metric_readers/cloudwatch.py: 100%

30 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-15 15:07 +0000

1"""Read-only CloudWatch datapoint reader. 

2 

3This module turns a single CloudWatch ``GetMetricStatistics`` request into one 

4numeric scalar a metric-threshold check can read. It has two pieces: 

5 

6* :func:`select_most_recent` — a pure helper that, given a non-empty list of 

7 CloudWatch datapoints, picks the one with the latest timestamp and returns 

8 its statistic value alongside that timestamp in ISO-8601 form. No I/O, so it 

9 is trivial to test in isolation. 

10* :func:`get_datapoint` — the thin boto3 wrapper. It constructs a 

11 region-scoped CloudWatch client, issues one read-only 

12 ``GetMetricStatistics`` call bounded to the requested window, and hands the 

13 returned datapoints to :func:`select_most_recent`. Every failure mode — 

14 an empty result, an unreachable endpoint, a credentials problem, or any 

15 other client error — is translated into a :class:`MetricReaderError` with a 

16 stable code so the calling tool can render a structured error envelope 

17 instead of crashing. 

18 

19``boto3`` is imported lazily inside :func:`get_datapoint` (mirroring the 

20lazy-import convention used by the SSM helpers and the stacks CLI) so this 

21module's import surface stays free of the SDK and tests can monkeypatch 

22``boto3.client`` without dragging the SDK into unrelated test runs. 

23""" 

24 

25from __future__ import annotations 

26 

27from datetime import datetime 

28from typing import Any 

29 

30from .shape import ErrorCode, MetricReaderError 

31 

32# CloudWatch ``Error.Code`` values that mean "the request was rejected because 

33# of credentials or permissions" rather than a transient transport failure. 

34# These map to the ``unauthorized`` discriminator so an operator can tell an 

35# access problem apart from an unreachable endpoint without reading the message. 

36_UNAUTHORIZED_ERROR_CODES = frozenset( 

37 { 

38 "AccessDenied", 

39 "AccessDeniedException", 

40 "AuthFailure", 

41 "ExpiredToken", 

42 "ExpiredTokenException", 

43 "InvalidAccessKeyId", 

44 "InvalidClientTokenId", 

45 "SignatureDoesNotMatch", 

46 "UnauthorizedOperation", 

47 "UnrecognizedClientException", 

48 } 

49) 

50 

51 

52def select_most_recent(datapoints: list[dict[str, Any]], statistic: str) -> tuple[float, str]: 

53 """Pick the latest datapoint and return its value and ISO timestamp. 

54 

55 Given a non-empty list of CloudWatch datapoints (each a dict carrying a 

56 ``Timestamp`` and the requested statistic key), return a 

57 ``(value, iso_timestamp)`` tuple where ``value`` is the chosen 

58 datapoint's ``statistic`` reading coerced to a float and ``iso_timestamp`` 

59 is its timestamp in ISO-8601 form. Selection is deterministic: the 

60 datapoint with the maximum ``Timestamp`` wins, and ``GetMetricStatistics`` 

61 never emits two datapoints sharing a timestamp within one period. 

62 

63 Args: 

64 datapoints: A non-empty list of CloudWatch datapoint dicts. 

65 statistic: The statistic key to read from the chosen datapoint 

66 (for example ``"Average"`` or ``"Sum"``). 

67 

68 Returns: 

69 A ``(value, iso_timestamp)`` tuple for the latest datapoint. 

70 """ 

71 most_recent = max(datapoints, key=lambda dp: dp["Timestamp"]) 

72 value = float(most_recent[statistic]) 

73 timestamp = most_recent["Timestamp"] 

74 iso_timestamp = timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp) 

75 return value, iso_timestamp 

76 

77 

78def get_datapoint( 

79 *, 

80 metric_name: str, 

81 namespace: str, 

82 dimensions: dict[str, str] | None, 

83 region: str, 

84 period: int, 

85 statistic: str, 

86 start_time: datetime, 

87 end_time: datetime, 

88) -> tuple[float, str]: 

89 """Read one CloudWatch datapoint for a metric in a named region. 

90 

91 Constructs a region-scoped CloudWatch client and issues a single 

92 read-only ``GetMetricStatistics`` request bounded to 

93 ``[start_time, end_time]`` for the supplied metric, namespace, 

94 dimensions, period, and statistic. The dimensions mapping is passed to 

95 CloudWatch unchanged, as a list of ``{"Name": ..., "Value": ...}`` pairs. 

96 

97 Args: 

98 metric_name: The CloudWatch metric name. 

99 namespace: The CloudWatch namespace the metric lives in. 

100 dimensions: Name/value dimension pairs, or ``None`` for no dimensions. 

101 region: The AWS region to scope the CloudWatch client to. 

102 period: The aggregation period, in seconds. 

103 statistic: The statistic to request (for example ``"Average"``). 

104 start_time: The inclusive start of the lookback window. 

105 end_time: The end of the lookback window. 

106 

107 Returns: 

108 A ``(value, iso_timestamp)`` tuple for the latest datapoint in the 

109 window, as produced by :func:`select_most_recent`. 

110 

111 Raises: 

112 MetricReaderError: With :attr:`ErrorCode.NO_DATAPOINTS` when the 

113 window holds no datapoints, or :attr:`ErrorCode.AWS_UNREACHABLE` 

114 (carrying a ``kind`` discriminator of ``unreachable``, 

115 ``unauthorized``, or ``client_error``) when the request cannot be 

116 completed. 

117 """ 

118 import boto3 

119 from botocore.exceptions import ( 

120 BotoCoreError, 

121 ClientError, 

122 EndpointConnectionError, 

123 ) 

124 

125 dimension_pairs = [{"Name": key, "Value": value} for key, value in (dimensions or {}).items()] 

126 

127 try: 

128 client = boto3.client("cloudwatch", region_name=region) 

129 response = client.get_metric_statistics( 

130 Namespace=namespace, 

131 MetricName=metric_name, 

132 Dimensions=dimension_pairs, 

133 StartTime=start_time, 

134 EndTime=end_time, 

135 Period=period, 

136 Statistics=[statistic], 

137 ) 

138 except EndpointConnectionError as exc: 

139 # A subclass of BotoCoreError; caught first so it keeps its own, 

140 # more specific "unreachable" classification. 

141 raise MetricReaderError( 

142 ErrorCode.AWS_UNREACHABLE, 

143 {"kind": "unreachable", "region": region, "message": str(exc)}, 

144 ) from exc 

145 except ClientError as exc: 

146 aws_error_code = exc.response.get("Error", {}).get("Code", "") 

147 kind = "unauthorized" if aws_error_code in _UNAUTHORIZED_ERROR_CODES else "client_error" 

148 raise MetricReaderError( 

149 ErrorCode.AWS_UNREACHABLE, 

150 { 

151 "kind": kind, 

152 "region": region, 

153 "aws_error_code": aws_error_code, 

154 "message": str(exc), 

155 }, 

156 ) from exc 

157 except BotoCoreError as exc: 

158 raise MetricReaderError( 

159 ErrorCode.AWS_UNREACHABLE, 

160 {"kind": "unreachable", "region": region, "message": str(exc)}, 

161 ) from exc 

162 

163 datapoints = response.get("Datapoints", []) 

164 if not datapoints: 

165 raise MetricReaderError( 

166 ErrorCode.NO_DATAPOINTS, 

167 { 

168 "metric_name": metric_name, 

169 "namespace": namespace, 

170 "region": region, 

171 "statistic": statistic, 

172 }, 

173 ) 

174 

175 return select_most_recent(datapoints, statistic)