diff --git a/CHANGELOG.md b/CHANGELOG.md index 364aa7b4..80f63af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t ## Unreleased +### Enhancements + +- Add Service and Environment dimensions to EMF metrics when `OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS` is enabled. + Supports platform-aware environment defaults (Lambda, EC2, ECS, EKS). + ([#299](https://github.com/aws-observability/aws-otel-js-instrumentation/pull/299)) + ## v0.8.0 - 2025-10-08 ### Enhancements diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/exporter/aws/metrics/emf-exporter-base.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/exporter/aws/metrics/emf-exporter-base.ts index 97723956..607891f8 100644 --- a/aws-distro-opentelemetry-node-autoinstrumentation/src/exporter/aws/metrics/emf-exporter-base.ts +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/exporter/aws/metrics/emf-exporter-base.ts @@ -19,7 +19,16 @@ import { ResourceMetrics, SumMetricData, } from '@opentelemetry/sdk-metrics'; -import { Resource } from '@opentelemetry/resources'; +import { defaultServiceName, Resource } from '@opentelemetry/resources'; +import { + SEMRESATTRS_CLOUD_PLATFORM, + SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, + SEMRESATTRS_SERVICE_NAME, + CLOUDPLATFORMVALUES_AWS_EC2, + CLOUDPLATFORMVALUES_AWS_ECS, + CLOUDPLATFORMVALUES_AWS_EKS, + CLOUDPLATFORMVALUES_AWS_LAMBDA, +} from '@opentelemetry/semantic-conventions'; import { ExportResult, ExportResultCode } from '@opentelemetry/core'; import type { LogEvent } from '@aws-sdk/client-cloudwatch-logs'; @@ -87,6 +96,17 @@ interface Metric { * * https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html */ +// Constants for Application Signals EMF dimensions +const SERVICE_DIMENSION = 'Service'; +const ENVIRONMENT_DIMENSION = 'Environment'; + +// Platform-specific default environments +const LAMBDA_DEFAULT = 'lambda:default'; +const EC2_DEFAULT = 'ec2:default'; +const ECS_DEFAULT = 'ecs:default'; +const EKS_DEFAULT = 'eks:default'; +const UNKNOWN_ENVIRONMENT = 'generic:default'; + export abstract class EMFExporterBase implements PushMetricExporter { private namespace: string; private aggregationTemporalitySelector: AggregationTemporalitySelector; @@ -188,6 +208,101 @@ export abstract class EMFExporterBase implements PushMetricExporter { return Object.keys(attributes); } + /** + * Check if Application Signals EMF dimensions should be added. + * + * Returns true when OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS is set to 'true'. + */ + private static shouldAddApplicationSignalsDimensions(): boolean { + return process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS']?.toLowerCase() === 'true'; + } + + /** + * Get the default environment based on the cloud platform. + */ + private getDefaultEnvironmentForPlatform(resource: Resource): string { + if (!resource?.attributes) { + return UNKNOWN_ENVIRONMENT; + } + + const cloudPlatform = resource.attributes[SEMRESATTRS_CLOUD_PLATFORM]; + + if (cloudPlatform) { + switch (String(cloudPlatform)) { + case CLOUDPLATFORMVALUES_AWS_LAMBDA: + return LAMBDA_DEFAULT; + case CLOUDPLATFORMVALUES_AWS_EC2: + return EC2_DEFAULT; + case CLOUDPLATFORMVALUES_AWS_ECS: + return ECS_DEFAULT; + case CLOUDPLATFORMVALUES_AWS_EKS: + return EKS_DEFAULT; + } + } + + return UNKNOWN_ENVIRONMENT; + } + + /** + * Check if dimension already exists (case-insensitive match). + */ + private hasDimensionCaseInsensitive(dimensionNames: string[], dimensionToCheck: string): boolean { + const dimensionLower = dimensionToCheck.toLowerCase(); + return dimensionNames.some(dim => dim.toLowerCase() === dimensionLower); + } + + /** + * Add Service and Environment dimensions if Application Signals dimensions are enabled + * and the dimensions are not already present (case-insensitive check). + */ + private addApplicationSignalsDimensions(dimensionNames: string[], emfLog: EMFLog, resource: Resource): void { + if (!EMFExporterBase.shouldAddApplicationSignalsDimensions()) { + return; + } + + // Add Service dimension if not already set by user + if (!this.hasDimensionCaseInsensitive(dimensionNames, SERVICE_DIMENSION)) { + let serviceName: string = 'UnknownService'; + if (resource?.attributes) { + const serviceAttr = resource.attributes[SEMRESATTRS_SERVICE_NAME]; + if (serviceAttr && serviceAttr !== defaultServiceName()) { + serviceName = String(serviceAttr); + } + } + dimensionNames.push(SERVICE_DIMENSION); + emfLog[SERVICE_DIMENSION] = serviceName; + } + + // Add Environment dimension if not already set by user + if (!this.hasDimensionCaseInsensitive(dimensionNames, ENVIRONMENT_DIMENSION)) { + let environment: string | undefined; + + if (resource?.attributes) { + // First check deployment.environment.name (newer semantic convention) + const envNameAttr = resource.attributes['deployment.environment.name']; + if (envNameAttr) { + environment = String(envNameAttr); + } + + // Then check deployment.environment (older semantic convention) + if (!environment) { + const envAttr = resource.attributes[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]; + if (envAttr) { + environment = String(envAttr); + } + } + } + + // Fall back to platform-specific default + if (!environment) { + environment = this.getDefaultEnvironmentForPlatform(resource); + } + + dimensionNames.push(ENVIRONMENT_DIMENSION); + emfLog[ENVIRONMENT_DIMENSION] = environment; + } + } + /** * Create a hashable key from attributes for grouping metrics. */ @@ -448,6 +563,9 @@ export abstract class EMFExporterBase implements PushMetricExporter { emfLog[name] = value?.toString() ?? 'undefined'; } + // Add Application Signals dimensions (Service and Environment) if enabled + this.addApplicationSignalsDimensions(dimensionNames, emfLog, resource); + // Add CloudWatch Metrics if we have metrics, include dimensions only if they exist if (metricDefinitions.length > 0) { const cloudWatchMetric: CloudWatchMetric = { diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/exporter/aws/metrics/aws-cloudwatch-emf-exporter.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/exporter/aws/metrics/aws-cloudwatch-emf-exporter.test.ts index ac5ed877..5bf914b9 100644 --- a/aws-distro-opentelemetry-node-autoinstrumentation/test/exporter/aws/metrics/aws-cloudwatch-emf-exporter.test.ts +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/exporter/aws/metrics/aws-cloudwatch-emf-exporter.test.ts @@ -829,4 +829,314 @@ describe('TestAWSCloudWatchEMFExporter', () => { expect(mockSendLogEvent.calledOnce).toBeTruthy(); expect(mockSendLogEvent.calledWith(logEvent)).toBeTruthy(); }); + + describe('Application Signals EMF Dimensions', () => { + let savedAddDimensionsEnv: string | undefined; + + beforeEach(() => { + // Save original env var + savedAddDimensionsEnv = process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS']; + }); + + afterEach(() => { + // Restore original env var + if (savedAddDimensionsEnv === undefined) { + delete process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS']; + } else { + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = savedAddDimensionsEnv; + } + }); + + it('TestDimensionsNotAddedWhenFeatureDisabled', () => { + /* Test that Service/Environment dimensions are NOT added when feature is disabled. */ + delete process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS']; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + // Should NOT have Service or Environment dimensions + expect(result).not.toHaveProperty('Service'); + expect(result).not.toHaveProperty('Environment'); + const cwMetrics = result._aws.CloudWatchMetrics[0]; + expect(cwMetrics.Dimensions![0]).not.toContain('Service'); + expect(cwMetrics.Dimensions![0]).not.toContain('Environment'); + }); + + it('TestDimensionsAddedWhenEnvVarEnabled', () => { + /* Test that Service/Environment dimensions ARE added when env var is enabled. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'production' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + // Should have Service and Environment dimensions + expect(result).toHaveProperty('Service', 'my-service'); + expect(result).toHaveProperty('Environment', 'production'); + const cwMetrics = result._aws.CloudWatchMetrics[0]; + expect(cwMetrics.Dimensions![0]).toContain('Service'); + expect(cwMetrics.Dimensions![0]).toContain('Environment'); + // Service should be first, Environment second + expect(cwMetrics.Dimensions![0][0]).toEqual('Service'); + expect(cwMetrics.Dimensions![0][1]).toEqual('Environment'); + }); + + it('TestServiceDimensionNotOverwrittenCaseInsensitive', () => { + /* Test that user-set Service dimension (any case) is NOT overwritten. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + // User sets 'service' (lowercase) as an attribute + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { service: 'user-service' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'resource-service' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + // Should NOT add 'Service' dimension since 'service' already exists + expect(result).not.toHaveProperty('Service'); + expect(result).toHaveProperty('service', 'user-service'); + // Environment should still be added + expect(result).toHaveProperty('Environment', 'generic:default'); + }); + + it('TestEnvironmentDimensionNotOverwrittenCaseInsensitive', () => { + /* Test that user-set Environment dimension (any case) is NOT overwritten. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + // User sets 'ENVIRONMENT' (uppercase) as an attribute + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { ENVIRONMENT: 'user-env' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'production' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + // Should NOT add 'Environment' dimension since 'ENVIRONMENT' already exists + expect(result).not.toHaveProperty('Environment'); + expect(result).toHaveProperty('ENVIRONMENT', 'user-env'); + // Service should still be added + expect(result).toHaveProperty('Service', 'my-service'); + }); + + it('TestServiceFallbackToUnknownService', () => { + /* Test that Service falls back to UnknownService when resource has no service.name. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + // Resource without service.name + const resource = new Resource({}); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Service', 'UnknownService'); + }); + + it('TestServiceFallbackWhenUnknownServicePattern', () => { + /* Test that Service falls back to UnknownService when resource has OTel default service name. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + // Resource with OTel default service name pattern + const { defaultServiceName } = require('@opentelemetry/resources'); + const resource = new Resource({ 'service.name': defaultServiceName() }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Service', 'UnknownService'); + }); + + it('TestEnvironmentFallbackToGenericDefault', () => { + /* Test that Environment falls back to generic:default when no platform is detected. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + // Resource without deployment.environment and no cloud.platform + const resource = new Resource({ 'service.name': 'my-service' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'generic:default'); + }); + + it('TestEnvironmentExtractedFromResource', () => { + /* Test that Environment is extracted from deployment.environment resource attribute. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'staging' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'staging'); + }); + + it('TestEnvironmentNameTakesPrecedenceOverEnvironment', () => { + /* Test that deployment.environment.name takes precedence over deployment.environment. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'deployment.environment': 'old-env', + 'deployment.environment.name': 'new-env', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'new-env'); + }); + + it('TestDimensionsAddedAlongsideExisting', () => { + /* Test that Service and Environment are added alongside existing dimensions. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { existing_dim: 'value' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'prod' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + const cwMetrics = result._aws.CloudWatchMetrics[0]; + // All three dimensions should be present + expect(cwMetrics.Dimensions![0]).toContain('Service'); + expect(cwMetrics.Dimensions![0]).toContain('Environment'); + expect(cwMetrics.Dimensions![0]).toContain('existing_dim'); + }); + + it('TestEnvVarCaseInsensitive', () => { + /* Test that env var value is case-insensitive (TRUE, True, true all work). */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'TRUE'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ 'service.name': 'my-service' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Service', 'my-service'); + expect(result).toHaveProperty('Environment', 'generic:default'); + }); + + it('TestLambdaPlatformDefault', () => { + /* Test that Lambda platform uses lambda:default. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'cloud.platform': 'aws_lambda', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'lambda:default'); + }); + + it('TestEC2PlatformDefault', () => { + /* Test that EC2 platform uses ec2:default. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'cloud.platform': 'aws_ec2', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'ec2:default'); + }); + + it('TestECSPlatformDefault', () => { + /* Test that ECS platform uses ecs:default. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'cloud.platform': 'aws_ecs', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'ecs:default'); + }); + + it('TestEKSPlatformDefault', () => { + /* Test that EKS platform uses eks:default. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'cloud.platform': 'aws_eks', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'eks:default'); + }); + + it('TestExplicitEnvironmentTakesPrecedenceOverPlatformDefault', () => { + /* Test that explicit deployment.environment takes precedence over platform default. */ + process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + const resource = new Resource({ + 'service.name': 'my-service', + 'cloud.platform': 'aws_lambda', + 'deployment.environment': 'production', + }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'production'); + }); + }); }); diff --git a/lambda-layer/packages/layer/scripts/otel-instrument b/lambda-layer/packages/layer/scripts/otel-instrument index 94ac4b0b..b0763dfa 100644 --- a/lambda-layer/packages/layer/scripts/otel-instrument +++ b/lambda-layer/packages/layer/scripts/otel-instrument @@ -76,6 +76,16 @@ if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" ]; then export OTEL_AWS_APPLICATION_SIGNALS_ENABLED="true"; fi +# - Set Application Signals EMF export configuration (adds Service and Environment dimensions) +if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED}" ]; then + export OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED="true"; +fi + +# - Set Application Signals dimensions configuration (adds Service and Environment dimensions to EMF metrics) +if [ -z "${OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS}" ]; then + export OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS="true"; +fi + # - Disable otel metrics export by default if [ -z "${OTEL_METRICS_EXPORTER}" ]; then export OTEL_METRICS_EXPORTER="none";