Skip to content

Commit a0da19f

Browse files
committed
Remove uneccessary _create_emf_exporter helper and fix a few bugs with improvements
1 parent 7de65e4 commit a0da19f

File tree

2 files changed

+91
-119
lines changed

2 files changed

+91
-119
lines changed

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/otlp/aws/metrics/aws_cloudwatch_emf_exporter.py

Lines changed: 70 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import json
77
import logging
8-
import os
98
import time
109
import uuid
1110
from collections import defaultdict
@@ -47,6 +46,38 @@ class AwsCloudWatchEMFExporter(MetricExporter):
4746
4847
"""
4948

49+
# CloudWatch EMF supported units
50+
# Ref: https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html
51+
EMF_SUPPORTED_UNITS = {
52+
"Seconds",
53+
"Microseconds",
54+
"Milliseconds",
55+
"Bytes",
56+
"Kilobytes",
57+
"Megabytes",
58+
"Gigabytes",
59+
"Terabytes",
60+
"Bits",
61+
"Kilobits",
62+
"Megabits",
63+
"Gigabits",
64+
"Terabits",
65+
"Percent",
66+
"Count",
67+
"Bytes/Second",
68+
"Kilobytes/Second",
69+
"Megabytes/Second",
70+
"Gigabytes/Second",
71+
"Terabytes/Second",
72+
"Bits/Second",
73+
"Kilobits/Second",
74+
"Megabits/Second",
75+
"Gigabits/Second",
76+
"Terabits/Second",
77+
"Count/Second",
78+
"None",
79+
}
80+
5081
# OTel to CloudWatch unit mapping
5182
# Ref: opentelemetry-collector-contrib/blob/main/exporter/awsemfexporter/grouped_metric.go#L188
5283
UNIT_MAPPING = {
@@ -79,17 +110,23 @@ def __init__(
79110
preferred_temporality: Optional dictionary mapping instrument types to aggregation temporality
80111
**kwargs: Additional arguments passed to botocore client
81112
"""
113+
# Set up temporality preference default to DELTA if customers not set
114+
if preferred_temporality is None:
115+
preferred_temporality = {
116+
Counter: AggregationTemporality.DELTA,
117+
Histogram: AggregationTemporality.DELTA,
118+
ObservableCounter: AggregationTemporality.DELTA,
119+
ObservableGauge: AggregationTemporality.DELTA,
120+
ObservableUpDownCounter: AggregationTemporality.DELTA,
121+
UpDownCounter: AggregationTemporality.DELTA,
122+
}
123+
82124
super().__init__(preferred_temporality)
83125

84126
self.namespace = namespace
85127
self.log_group_name = log_group_name
86128
self.log_stream_name = log_stream_name or self._generate_log_stream_name()
87129

88-
# Initialize CloudWatch Logs client using botocore
89-
# If aws_region is not provided, botocore will check environment variables AWS_REGION or AWS_DEFAULT_REGION
90-
if aws_region is None:
91-
aws_region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
92-
93130
session = botocore.session.Session()
94131
self.logs_client = session.create_client("logs", region_name=aws_region, **kwargs)
95132

@@ -99,6 +136,8 @@ def __init__(
99136
# Ensure log stream exists
100137
self._ensure_log_stream_exists()
101138

139+
# Default to unique log stream name matching OTel Collector
140+
# EMF Exporter behavior with language for source identification
102141
def _generate_log_stream_name(self) -> str:
103142
"""Generate a unique log stream name."""
104143

@@ -108,12 +147,12 @@ def _generate_log_stream_name(self) -> str:
108147
def _ensure_log_group_exists(self):
109148
"""Ensure the log group exists, create if it doesn't."""
110149
try:
111-
self.logs_client.describe_log_groups(logGroupNamePrefix=self.log_group_name, limit=1)
112-
except ClientError:
113-
try:
114-
self.logs_client.create_log_group(logGroupName=self.log_group_name)
115-
logger.info("Created log group: %s", self.log_group_name)
116-
except ClientError as error:
150+
self.logs_client.create_log_group(logGroupName=self.log_group_name)
151+
logger.info("Created log group: %s", self.log_group_name)
152+
except ClientError as error:
153+
if error.response.get("Error", {}).get("Code") == "ResourceAlreadyExistsException":
154+
logger.debug("Log group %s already exists", self.log_group_name)
155+
else:
117156
logger.error("Failed to create log group %s : %s", self.log_group_name, error)
118157
raise
119158

@@ -130,7 +169,7 @@ def _ensure_log_stream_exists(self):
130169

131170
def _get_metric_name(self, record: Any) -> Optional[str]:
132171
"""Get the metric name from the metric record or data point."""
133-
# For compatibility with older record format
172+
134173
if hasattr(record, "instrument") and hasattr(record.instrument, "name") and record.instrument.name:
135174
return record.instrument.name
136175
# Return None if no valid metric name found
@@ -147,7 +186,17 @@ def _get_unit(self, instrument_or_metric: Any) -> Optional[str]:
147186
if not unit:
148187
return None
149188

150-
return self.UNIT_MAPPING.get(unit, unit)
189+
# First check if unit is already a supported EMF unit
190+
if unit in self.EMF_SUPPORTED_UNITS:
191+
return unit
192+
193+
# Otherwise, try to map from OTel unit to CloudWatch unit
194+
mapped_unit = self.UNIT_MAPPING.get(unit)
195+
if mapped_unit is not None:
196+
return mapped_unit
197+
198+
# If unit is not supported, return None
199+
return None
151200

152201
def _get_dimension_names(self, attributes: Dict[str, Any]) -> List[str]:
153202
"""Extract dimension names from attributes."""
@@ -185,7 +234,11 @@ def _normalize_timestamp(self, timestamp_ns: int) -> int:
185234

186235
# pylint: disable=no-member
187236
def _create_metric_record(self, metric_name: str, metric_unit: str, metric_description: str) -> Any:
188-
"""Create a base metric record with instrument information.
237+
"""
238+
Creates the intermediate metric data structure that standardizes different otel metric representation
239+
and will be used to generate EMF events. The base record
240+
establishes the instrument schema (name/unit/description) that will be populated
241+
with dimensions, timestamps, and values during metric processing.
189242
190243
Args:
191244
metric_name: Name of the metric
@@ -255,6 +308,7 @@ def _create_emf_log(self, metric_records: List[Any], resource: Resource, timesta
255308
emf_log = {"_aws": {"Timestamp": timestamp or int(time.time() * 1000), "CloudWatchMetrics": []}}
256309

257310
# Set with latest EMF version schema
311+
# opentelemetry-collector-contrib/blob/main/exporter/awsemfexporter/metric_translator.go#L414
258312
emf_log["Version"] = "1"
259313

260314
# Add resource attributes to EMF log but not as dimensions
@@ -267,9 +321,7 @@ def _create_emf_log(self, metric_records: List[Any], resource: Resource, timesta
267321
emf_log[f"otel.resource.{key}"] = str(value)
268322

269323
# Initialize collections for dimensions and metrics
270-
271324
metric_definitions = []
272-
273325
# Collect attributes from all records (they should be the same for all records in the group)
274326
# Only collect once from the first record and apply to all records
275327
all_attributes = (
@@ -339,7 +391,7 @@ def _send_log_event(self, log_event: Dict[str, Any]):
339391
return response
340392

341393
except ClientError as error:
342-
logger.error("Failed to send log event: %s", error)
394+
logger.debug("Failed to send log event: %s", error)
343395
raise
344396

345397
# pylint: disable=too-many-nested-blocks
@@ -438,46 +490,3 @@ def shutdown(self, timeout_millis: Optional[int] = None, **kwargs: Any) -> bool:
438490
self.force_flush(timeout_millis)
439491
logger.debug("AwsCloudWatchEMFExporter shutdown called with timeout_millis=%s", timeout_millis)
440492
return True
441-
442-
443-
def create_emf_exporter(
444-
namespace: str = "OTelPython",
445-
log_group_name: str = "/aws/otel/python",
446-
log_stream_name: Optional[str] = None,
447-
aws_region: Optional[str] = None,
448-
**kwargs,
449-
) -> AwsCloudWatchEMFExporter:
450-
"""
451-
Convenience function to create a CloudWatch EMF exporter with DELTA temporality.
452-
453-
Args:
454-
namespace: CloudWatch namespace for metrics
455-
log_group_name: CloudWatch log group name
456-
log_stream_name: CloudWatch log stream name (auto-generated if None)
457-
aws_region: AWS region (auto-detected if None)
458-
debug: Whether to enable debug printing of EMF logs
459-
**kwargs: Additional arguments passed to the AwsCloudWatchEMFExporter
460-
461-
Returns:
462-
Configured AwsCloudWatchEMFExporter instance
463-
"""
464-
465-
# Set up temporality preference - always use DELTA for CloudWatch
466-
temporality_dict = {
467-
Counter: AggregationTemporality.DELTA,
468-
Histogram: AggregationTemporality.DELTA,
469-
ObservableCounter: AggregationTemporality.DELTA,
470-
ObservableGauge: AggregationTemporality.DELTA,
471-
ObservableUpDownCounter: AggregationTemporality.DELTA,
472-
UpDownCounter: AggregationTemporality.DELTA,
473-
}
474-
475-
# Create and return the exporter
476-
return AwsCloudWatchEMFExporter(
477-
namespace=namespace,
478-
log_group_name=log_group_name,
479-
log_stream_name=log_stream_name,
480-
aws_region=aws_region,
481-
preferred_temporality=temporality_dict,
482-
**kwargs,
483-
)

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/otlp/aws/metrics/test_aws_cloudwatch_emf_exporter.py

Lines changed: 21 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@
88

99
from botocore.exceptions import ClientError
1010

11-
from amazon.opentelemetry.distro.exporter.otlp.aws.metrics.aws_cloudwatch_emf_exporter import (
12-
AwsCloudWatchEMFExporter,
13-
create_emf_exporter,
14-
)
11+
from amazon.opentelemetry.distro.exporter.otlp.aws.metrics.aws_cloudwatch_emf_exporter import AwsCloudWatchEMFExporter
1512
from opentelemetry.sdk.metrics.export import Gauge, MetricExportResult
1613
from opentelemetry.sdk.resources import Resource
1714

@@ -67,45 +64,6 @@ def __init__(self, scope=None, metrics=None):
6764
self.metrics = metrics or []
6865

6966

70-
class TestCreateEMFExporter(unittest.TestCase):
71-
"""Test the create_emf_exporter function."""
72-
73-
@patch("botocore.session.Session")
74-
def test_create_emf_exporter_default_args(self, mock_session):
75-
"""Test creating exporter with default arguments."""
76-
# Mock the botocore session to avoid AWS calls
77-
mock_client = Mock()
78-
mock_session_instance = Mock()
79-
mock_session.return_value = mock_session_instance
80-
mock_session_instance.create_client.return_value = mock_client
81-
mock_client.describe_log_groups.return_value = {"logGroups": []}
82-
mock_client.create_log_group.return_value = {}
83-
84-
exporter = create_emf_exporter()
85-
86-
self.assertIsInstance(exporter, AwsCloudWatchEMFExporter)
87-
self.assertEqual(exporter.namespace, "OTelPython")
88-
89-
@patch("botocore.session.Session")
90-
def test_create_emf_exporter_custom_args(self, mock_session):
91-
"""Test creating exporter with custom arguments."""
92-
# Mock the botocore session to avoid AWS calls
93-
mock_client = Mock()
94-
mock_session_instance = Mock()
95-
mock_session.return_value = mock_session_instance
96-
mock_session_instance.create_client.return_value = mock_client
97-
mock_client.describe_log_groups.return_value = {"logGroups": []}
98-
mock_client.create_log_group.return_value = {}
99-
100-
exporter = create_emf_exporter(
101-
namespace="CustomNamespace", log_group_name="/custom/log/group", aws_region="us-west-2"
102-
)
103-
104-
self.assertIsInstance(exporter, AwsCloudWatchEMFExporter)
105-
self.assertEqual(exporter.namespace, "CustomNamespace")
106-
self.assertEqual(exporter.log_group_name, "/custom/log/group")
107-
108-
10967
# pylint: disable=too-many-public-methods
11068
class TestAwsCloudWatchEMFExporter(unittest.TestCase):
11169
"""Test AwsCloudWatchEMFExporter class."""
@@ -118,8 +76,8 @@ def setUp(self):
11876
mock_session_instance = Mock()
11977
mock_session.return_value = mock_session_instance
12078
mock_session_instance.create_client.return_value = mock_client
121-
mock_client.describe_log_groups.return_value = {"logGroups": []}
12279
mock_client.create_log_group.return_value = {}
80+
mock_client.create_log_stream.return_value = {}
12381

12482
self.exporter = AwsCloudWatchEMFExporter(namespace="TestNamespace", log_group_name="test-log-group")
12583

@@ -137,8 +95,8 @@ def test_initialization_with_custom_params(self, mock_session):
13795
mock_session_instance = Mock()
13896
mock_session.return_value = mock_session_instance
13997
mock_session_instance.create_client.return_value = mock_client
140-
mock_client.describe_log_groups.return_value = {"logGroups": []}
14198
mock_client.create_log_group.return_value = {}
99+
mock_client.create_log_stream.return_value = {}
142100

143101
exporter = AwsCloudWatchEMFExporter(
144102
namespace="CustomNamespace",
@@ -159,12 +117,17 @@ def test_get_unit_mapping(self):
159117
self.assertEqual(self.exporter._get_unit(Mock(unit="By")), "Bytes")
160118
self.assertEqual(self.exporter._get_unit(Mock(unit="bit")), "Bits")
161119

162-
# Test units that map to empty string
120+
# Test units that map to empty string (should return empty string from mapping)
163121
self.assertEqual(self.exporter._get_unit(Mock(unit="1")), "")
164122
self.assertEqual(self.exporter._get_unit(Mock(unit="ns")), "")
165123

166-
# Test unknown unit (returns as-is)
167-
self.assertEqual(self.exporter._get_unit(Mock(unit="unknown")), "unknown")
124+
# Test EMF supported units directly (should return as-is)
125+
self.assertEqual(self.exporter._get_unit(Mock(unit="Count")), "Count")
126+
self.assertEqual(self.exporter._get_unit(Mock(unit="Percent")), "Percent")
127+
self.assertEqual(self.exporter._get_unit(Mock(unit="Kilobytes")), "Kilobytes")
128+
129+
# Test unknown unit (not in mapping and not in supported units, returns None)
130+
self.assertIsNone(self.exporter._get_unit(Mock(unit="unknown")))
168131

169132
# Test empty unit (should return None due to falsy check)
170133
self.assertIsNone(self.exporter._get_unit(Mock(unit="")))
@@ -491,8 +454,8 @@ def test_initialization_with_env_region(self, mock_session, mock_env_get):
491454
mock_session_instance = Mock()
492455
mock_session.return_value = mock_session_instance
493456
mock_session_instance.create_client.return_value = mock_client
494-
mock_client.describe_log_groups.return_value = {"logGroups": []}
495457
mock_client.create_log_group.return_value = {}
458+
mock_client.create_log_stream.return_value = {}
496459

497460
exporter = AwsCloudWatchEMFExporter(namespace="TestNamespace", log_group_name="test-log-group")
498461

@@ -509,11 +472,9 @@ def test_ensure_log_group_exists_create_failure(self, mock_session):
509472
mock_session.return_value = mock_session_instance
510473
mock_session_instance.create_client.return_value = mock_client
511474

512-
# Make describe fail and create fail with a different error
513-
mock_client.describe_log_groups.side_effect = ClientError(
514-
{"Error": {"Code": "AccessDenied"}}, "DescribeLogGroups"
515-
)
475+
# Make create fail with access denied error
516476
mock_client.create_log_group.side_effect = ClientError({"Error": {"Code": "AccessDenied"}}, "CreateLogGroup")
477+
mock_client.create_log_stream.return_value = {}
517478

518479
with self.assertRaises(ClientError):
519480
AwsCloudWatchEMFExporter(namespace="TestNamespace", log_group_name="test-log-group")
@@ -527,15 +488,17 @@ def test_ensure_log_group_exists_success(self, mock_session):
527488
mock_session.return_value = mock_session_instance
528489
mock_session_instance.create_client.return_value = mock_client
529490

530-
# Make describe succeed (log group exists)
531-
mock_client.describe_log_groups.return_value = {"logGroups": [{"logGroupName": "test-log-group"}]}
491+
# Make create fail with ResourceAlreadyExistsException (log group exists)
492+
mock_client.create_log_group.side_effect = ClientError(
493+
{"Error": {"Code": "ResourceAlreadyExistsException"}}, "CreateLogGroup"
494+
)
495+
mock_client.create_log_stream.return_value = {}
532496

533497
# This should not raise an exception
534498
exporter = AwsCloudWatchEMFExporter(namespace="TestNamespace", log_group_name="test-log-group")
535499
self.assertIsNotNone(exporter)
536-
# Verify describe was called but create was not
537-
mock_client.describe_log_groups.assert_called_once_with(logGroupNamePrefix="test-log-group", limit=1)
538-
mock_client.create_log_group.assert_not_called()
500+
# Verify create was called once
501+
mock_client.create_log_group.assert_called_once_with(logGroupName="test-log-group")
539502

540503
def test_export_with_unsupported_metric_type(self):
541504
"""Test export with unsupported metric types."""

0 commit comments

Comments
 (0)