Skip to content

Commit add1a3c

Browse files
Add Service and Environment dimensions to EMF metrics when Application Signals is enabled
When both OTEL_AWS_APPLICATION_SIGNALS_ENABLED and OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED are true, inject "Service" and "Environment" dimensions into EMF metrics. - Service: extracted from resource via get_service_attribute(), falls back to "UnknownService" - Environment: extracted from deployment.environment resource attribute, falls back to "lambda:default" - Case-insensitive check prevents overwriting user-set dimensions - Lambda wrapper sets OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED=true by default 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 72625db commit add1a3c

File tree

5 files changed

+234
-3
lines changed

5 files changed

+234
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ For any change that affects end users of this package, please add an entry under
1111
If your change does not need a CHANGELOG entry, add the "skip changelog" label to your PR.
1212

1313
## Unreleased
14+
- Add Service and Environment dimensions to EMF metrics when Application Signals EMF export is enabled
15+
([#548](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/548))
1416
- Add Resource and CFN Attributes for Bedrock AgentCore spans
1517
([#495](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/495))
1618
- Add botocore instrumentation extension for Bedrock AgentCore services with span attributes

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/aws/metrics/base_emf_exporter.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import json
77
import logging
88
import math
9+
import os
910
import time
1011
from abc import ABC, abstractmethod
1112
from collections import defaultdict
1213
from typing import Any, Dict, List, Optional, Tuple
1314

15+
from amazon.opentelemetry.distro._aws_resource_attribute_configurator import get_service_attribute
1416
from opentelemetry.sdk.metrics import Counter
1517
from opentelemetry.sdk.metrics import Histogram as HistogramInstr
1618
from opentelemetry.sdk.metrics import ObservableCounter, ObservableGauge, ObservableUpDownCounter, UpDownCounter
@@ -28,6 +30,7 @@
2830
)
2931
from opentelemetry.sdk.metrics.view import ExponentialBucketHistogramAggregation
3032
from opentelemetry.sdk.resources import Resource
33+
from opentelemetry.semconv.resource import ResourceAttributes
3134
from opentelemetry.util.types import Attributes
3235

3336
logger = logging.getLogger(__name__)
@@ -184,6 +187,53 @@ def _get_dimension_names(self, attributes: Attributes) -> List[str]:
184187
# For now, use all attributes as dimensions
185188
return list(attributes.keys())
186189

190+
def _has_dimension_case_insensitive(self, dimension_names: List[str], dimension_to_check: str) -> bool:
191+
"""Check if dimension already exists (case-insensitive match)."""
192+
dimension_lower = dimension_to_check.lower()
193+
return any(dim.lower() == dimension_lower for dim in dimension_names)
194+
195+
@staticmethod
196+
def _is_application_signals_emf_export_enabled() -> bool:
197+
"""Check if Application Signals EMF export is enabled.
198+
199+
Returns True only if BOTH:
200+
- OTEL_AWS_APPLICATION_SIGNALS_ENABLED is true
201+
- OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED is true
202+
"""
203+
app_signals_enabled = os.environ.get("OTEL_AWS_APPLICATION_SIGNALS_ENABLED", "false").lower() == "true"
204+
emf_export_enabled = (
205+
os.environ.get("OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED", "false").lower() == "true"
206+
)
207+
return app_signals_enabled and emf_export_enabled
208+
209+
def _add_application_signals_dimensions(
210+
self, dimension_names: List[str], emf_log: Dict, resource: Resource
211+
) -> None:
212+
"""Add Service and Environment dimensions if not already present (case-insensitive)."""
213+
if not self._is_application_signals_emf_export_enabled():
214+
return
215+
216+
# Add Service dimension if not already set by user
217+
if not self._has_dimension_case_insensitive(dimension_names, "Service"):
218+
if resource:
219+
service_name, _ = get_service_attribute(resource)
220+
else:
221+
service_name = "UnknownService"
222+
dimension_names.insert(0, "Service")
223+
emf_log["Service"] = str(service_name)
224+
225+
# Add Environment dimension if not already set by user
226+
if not self._has_dimension_case_insensitive(dimension_names, "Environment"):
227+
environment_value = None
228+
if resource and resource.attributes:
229+
environment_value = resource.attributes.get(ResourceAttributes.DEPLOYMENT_ENVIRONMENT)
230+
if not environment_value:
231+
environment_value = "lambda:default"
232+
# Insert after Service if it exists, otherwise at the beginning
233+
insert_pos = 1 if "Service" in dimension_names else 0
234+
dimension_names.insert(insert_pos, "Environment")
235+
emf_log["Environment"] = str(environment_value)
236+
187237
def _get_attributes_key(self, attributes: Attributes) -> str:
188238
"""
189239
Create a hashable key from attributes for grouping metrics.
@@ -493,6 +543,9 @@ def _create_emf_log(
493543
for name, value in all_attributes.items():
494544
emf_log[name] = str(value)
495545

546+
# Add Service and Environment dimensions if Application Signals EMF export is enabled
547+
self._add_application_signals_dimensions(dimension_names, emf_log, resource)
548+
496549
# Add CloudWatch Metrics if we have metrics, include dimensions only if they exist
497550
if metric_definitions:
498551
cloudwatch_metric = {"Namespace": self.namespace, "Metrics": metric_definitions}

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/aws/metrics/test_base_emf_exporter.py

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import os
45
import unittest
5-
from unittest.mock import Mock
6+
from unittest.mock import Mock, patch
67

78
from amazon.opentelemetry.distro.exporter.aws.metrics.base_emf_exporter import BaseEmfExporter, MetricRecord
89
from opentelemetry.sdk.metrics.export import MetricExportResult
@@ -286,6 +287,173 @@ def test_export_failure_handling(self):
286287
result = self.exporter.export(metrics_data)
287288
self.assertEqual(result, MetricExportResult.FAILURE)
288289

290+
def test_has_dimension_case_insensitive(self):
291+
"""Test case-insensitive dimension checking."""
292+
dimension_names = ["Service", "Environment", "operation"]
293+
294+
# Exact match
295+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "Service"))
296+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "Environment"))
297+
298+
# Case variations
299+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "service"))
300+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "SERVICE"))
301+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "environment"))
302+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "ENVIRONMENT"))
303+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "OPERATION"))
304+
305+
# Non-existent dimension
306+
self.assertFalse(self.exporter._has_dimension_case_insensitive(dimension_names, "NotExists"))
307+
308+
# Empty list
309+
self.assertFalse(self.exporter._has_dimension_case_insensitive([], "Service"))
310+
311+
def test_add_application_signals_dimensions_disabled(self):
312+
"""Test that dimensions are not added when feature is disabled."""
313+
# Default exporter has feature disabled
314+
dimension_names = ["operation"]
315+
emf_log = {}
316+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
317+
318+
self.exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
319+
320+
# Dimensions should not be added
321+
self.assertEqual(dimension_names, ["operation"])
322+
self.assertNotIn("Service", emf_log)
323+
self.assertNotIn("Environment", emf_log)
324+
325+
@patch.dict(
326+
os.environ,
327+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
328+
)
329+
def test_add_application_signals_dimensions_enabled(self):
330+
"""Test that dimensions are added when feature is enabled."""
331+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
332+
dimension_names = ["operation"]
333+
emf_log = {}
334+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
335+
336+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
337+
338+
# Service and Environment should be added at the beginning
339+
self.assertEqual(dimension_names, ["Service", "Environment", "operation"])
340+
self.assertEqual(emf_log["Service"], "my-service")
341+
self.assertEqual(emf_log["Environment"], "production")
342+
343+
@patch.dict(
344+
os.environ,
345+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
346+
)
347+
def test_add_application_signals_dimensions_fallback_values(self):
348+
"""Test fallback values when resource attributes are not available."""
349+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
350+
dimension_names = ["operation"]
351+
emf_log = {}
352+
# Resource without deployment.environment
353+
resource = Resource.create({"service.name": "my-service"})
354+
355+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
356+
357+
# Service should use service.name, Environment should fallback to lambda:default
358+
self.assertIn("Service", dimension_names)
359+
self.assertIn("Environment", dimension_names)
360+
self.assertEqual(emf_log["Service"], "my-service")
361+
self.assertEqual(emf_log["Environment"], "lambda:default")
362+
363+
@patch.dict(
364+
os.environ,
365+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
366+
)
367+
def test_add_application_signals_dimensions_no_resource(self):
368+
"""Test fallback when resource is None."""
369+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
370+
dimension_names = ["operation"]
371+
emf_log = {}
372+
373+
exporter._add_application_signals_dimensions(dimension_names, emf_log, None)
374+
375+
# Should use fallback values
376+
self.assertIn("Service", dimension_names)
377+
self.assertIn("Environment", dimension_names)
378+
self.assertEqual(emf_log["Service"], "UnknownService")
379+
self.assertEqual(emf_log["Environment"], "lambda:default")
380+
381+
@patch.dict(
382+
os.environ,
383+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
384+
)
385+
def test_add_application_signals_dimensions_service_already_set(self):
386+
"""Test that Service dimension is not overwritten if already set (case-insensitive)."""
387+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
388+
389+
# User has set "service" (lowercase)
390+
dimension_names = ["service", "operation"]
391+
emf_log = {"service": "user-service"}
392+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
393+
394+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
395+
396+
# Service should NOT be added (case-insensitive match), but Environment should be
397+
self.assertIn("Environment", dimension_names)
398+
self.assertNotIn("Service", dimension_names) # "Service" not added because "service" exists
399+
self.assertEqual(emf_log.get("service"), "user-service") # User value preserved
400+
self.assertEqual(emf_log.get("Environment"), "production")
401+
402+
@patch.dict(
403+
os.environ,
404+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
405+
)
406+
def test_add_application_signals_dimensions_environment_already_set(self):
407+
"""Test that Environment dimension is not overwritten if already set (case-insensitive)."""
408+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
409+
410+
# User has set "ENVIRONMENT" (uppercase)
411+
dimension_names = ["ENVIRONMENT", "operation"]
412+
emf_log = {"ENVIRONMENT": "user-environment"}
413+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
414+
415+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
416+
417+
# Environment should NOT be added (case-insensitive match), but Service should be
418+
self.assertIn("Service", dimension_names)
419+
self.assertNotIn("Environment", dimension_names) # "Environment" not added because "ENVIRONMENT" exists
420+
self.assertEqual(emf_log.get("Service"), "my-service")
421+
self.assertEqual(emf_log.get("ENVIRONMENT"), "user-environment") # User value preserved
422+
423+
@patch.dict(
424+
os.environ,
425+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
426+
)
427+
def test_create_emf_log_with_application_signals_enabled(self):
428+
"""Test EMF log creation with Application Signals EMF export enabled."""
429+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
430+
431+
record = exporter._create_metric_record("test_metric", "Count", "Test")
432+
record.value = 50.0
433+
record.timestamp = 1234567890
434+
record.attributes = {"operation": "test"}
435+
436+
records = [record]
437+
resource = Resource.create(
438+
{
439+
"service.name": "test-service",
440+
"deployment.environment": "production",
441+
}
442+
)
443+
444+
result = exporter._create_emf_log(records, resource, 1234567890)
445+
446+
# Check that Service and Environment dimensions were added
447+
self.assertEqual(result["Service"], "test-service")
448+
self.assertEqual(result["Environment"], "production")
449+
450+
# Check CloudWatch metrics dimensions include Service and Environment
451+
cw_metrics = result["_aws"]["CloudWatchMetrics"][0]
452+
dimensions = cw_metrics["Dimensions"][0]
453+
self.assertIn("Service", dimensions)
454+
self.assertIn("Environment", dimensions)
455+
self.assertIn("operation", dimensions)
456+
289457

290458
if __name__ == "__main__":
291459
unittest.main()

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/test_aws_opentelementry_configurator.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,7 +1327,9 @@ def test_create_emf_exporter_lambda_without_valid_headers(
13271327
result = _create_emf_exporter()
13281328

13291329
self.assertEqual(result, mock_exporter_instance)
1330-
mock_console_exporter.assert_called_once_with(namespace="test-namespace")
1330+
mock_console_exporter.assert_called_once_with(
1331+
namespace="test-namespace",
1332+
)
13311333

13321334
@patch("amazon.opentelemetry.distro.aws_opentelemetry_configurator._fetch_logs_header")
13331335
@patch("amazon.opentelemetry.distro.aws_opentelemetry_configurator._is_lambda_environment")
@@ -1474,7 +1476,9 @@ def test_create_emf_exporter_lambda_without_valid_headers_none_namespace(
14741476
result = _create_emf_exporter()
14751477

14761478
self.assertEqual(result, mock_exporter_instance)
1477-
mock_console_exporter.assert_called_once_with(namespace=None)
1479+
mock_console_exporter.assert_called_once_with(
1480+
namespace=None,
1481+
)
14781482

14791483
@patch("amazon.opentelemetry.distro.aws_opentelemetry_configurator._fetch_logs_header")
14801484
@patch("amazon.opentelemetry.distro.aws_opentelemetry_configurator._is_lambda_environment")

lambda-layer/src/otel-instrument

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" ]; then
115115
export OTEL_AWS_APPLICATION_SIGNALS_ENABLED="true";
116116
fi
117117

118+
if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED}" ]; then
119+
export OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED="true";
120+
fi
121+
118122
# - If Application Signals is enabled
119123

120124
if [ "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" = "true" ]; then

0 commit comments

Comments
 (0)