diff --git a/CHANGELOG.md b/CHANGELOG.md index 364aa7b4..e72076c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ 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 Application Signals is enabled + ([#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..9925e9f9 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,8 @@ import { ResourceMetrics, SumMetricData, } from '@opentelemetry/sdk-metrics'; -import { Resource } from '@opentelemetry/resources'; +import { defaultServiceName, Resource } from '@opentelemetry/resources'; +import { SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; import { ExportResult, ExportResultCode } from '@opentelemetry/core'; import type { LogEvent } from '@aws-sdk/client-cloudwatch-logs'; @@ -188,6 +189,65 @@ export abstract class EMFExporterBase implements PushMetricExporter { return Object.keys(attributes); } + /** + * Check if Application Signals EMF export is enabled. + * + * Returns true only if BOTH: + * - OTEL_AWS_APPLICATION_SIGNALS_ENABLED is true + * - OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED is true + */ + private static isApplicationSignalsEmfExportEnabled(): boolean { + const appSignalsEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']?.toLowerCase() === 'true'; + const emfExportEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']?.toLowerCase() === 'true'; + return appSignalsEnabled && emfExportEnabled; + } + + /** + * 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 EMF export is enabled + * and the dimensions are not already present (case-insensitive check). + */ + private addApplicationSignalsDimensions(dimensionNames: string[], emfLog: EMFLog, resource: Resource): void { + if (!EMFExporterBase.isApplicationSignalsEmfExportEnabled()) { + return; + } + + // Add Service dimension if not already set by user + if (!this.hasDimensionCaseInsensitive(dimensionNames, 'Service')) { + let serviceName: string = 'UnknownService'; + if (resource?.attributes) { + const serviceAttr = resource.attributes[SEMRESATTRS_SERVICE_NAME]; + if (serviceAttr && serviceAttr !== defaultServiceName()) { + serviceName = String(serviceAttr); + } + } + dimensionNames.unshift('Service'); + emfLog['Service'] = serviceName; + } + + // Add Environment dimension if not already set by user + if (!this.hasDimensionCaseInsensitive(dimensionNames, 'Environment')) { + let environment: string = 'lambda:default'; + if (resource?.attributes) { + const envAttr = resource.attributes[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]; + if (envAttr) { + environment = String(envAttr); + } + } + // Insert after Service if present, otherwise at beginning + const insertIndex = dimensionNames.includes('Service') ? 1 : 0; + dimensionNames.splice(insertIndex, 0, 'Environment'); + emfLog['Environment'] = environment; + } + } + /** * Create a hashable key from attributes for grouping metrics. */ @@ -443,6 +503,9 @@ export abstract class EMFExporterBase implements PushMetricExporter { const dimensionNames = this.getDimensionNames(allAttributes); + // Add Application Signals dimensions (Service and Environment) if enabled + this.addApplicationSignalsDimensions(dimensionNames, emfLog, resource); + // Add attribute values to the root of the EMF log for (const [name, value] of Object.entries(allAttributes)) { emfLog[name] = value?.toString() ?? 'undefined'; 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..bc3aa903 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,255 @@ describe('TestAWSCloudWatchEMFExporter', () => { expect(mockSendLogEvent.calledOnce).toBeTruthy(); expect(mockSendLogEvent.calledWith(logEvent)).toBeTruthy(); }); + + describe('Application Signals EMF Dimensions', () => { + let savedAppSignalsEnabled: string | undefined; + let savedEmfExportEnabled: string | undefined; + + beforeEach(() => { + // Save original env vars + savedAppSignalsEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']; + savedEmfExportEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']; + }); + + afterEach(() => { + // Restore original env vars + if (savedAppSignalsEnabled === undefined) { + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']; + } else { + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = savedAppSignalsEnabled; + } + if (savedEmfExportEnabled === undefined) { + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']; + } else { + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = savedEmfExportEnabled; + } + }); + + it('TestDimensionsNotAddedWhenFeatureDisabled', () => { + /* Test that Service/Environment dimensions are NOT added when feature is disabled. */ + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']; + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']; + + 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('TestDimensionsAddedWhenBothEnvVarsEnabled', () => { + /* Test that Service/Environment dimensions ARE added when both env vars are enabled. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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('TestDimensionsNotAddedWhenOnlyAppSignalsEnabled', () => { + /* Test that dimensions are NOT added when only APPLICATION_SIGNALS_ENABLED is set. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']; + + 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).not.toHaveProperty('Service'); + expect(result).not.toHaveProperty('Environment'); + }); + + it('TestDimensionsNotAddedWhenOnlyEmfExportEnabled', () => { + /* Test that dimensions are NOT added when only EMF_EXPORT_ENABLED is set. */ + delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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).not.toHaveProperty('Service'); + expect(result).not.toHaveProperty('Environment'); + }); + + it('TestServiceDimensionNotOverwrittenCaseInsensitive', () => { + /* Test that user-set Service dimension (any case) is NOT overwritten. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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', 'lambda:default'); + }); + + it('TestEnvironmentDimensionNotOverwrittenCaseInsensitive', () => { + /* Test that user-set Environment dimension (any case) is NOT overwritten. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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('TestEnvironmentFallbackToLambdaDefault', () => { + /* Test that Environment falls back to lambda:default when not set in resource. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true'; + + const gaugeRecord: MetricRecord = { + ...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }), + value: 50.0, + }; + + // Resource without deployment.environment + const resource = new Resource({ 'service.name': 'my-service' }); + const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890); + + expect(result).toHaveProperty('Environment', 'lambda:default'); + }); + + it('TestEnvironmentExtractedFromResource', () => { + /* Test that Environment is extracted from deployment.environment resource attribute. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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('TestDimensionOrderServiceThenEnvironment', () => { + /* Test that Service comes before Environment in dimensions array. */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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]; + // Dimensions should be: ['Service', 'Environment', 'existing_dim'] + expect(cwMetrics.Dimensions![0][0]).toEqual('Service'); + expect(cwMetrics.Dimensions![0][1]).toEqual('Environment'); + expect(cwMetrics.Dimensions![0][2]).toEqual('existing_dim'); + }); + + it('TestEnvVarsCaseInsensitive', () => { + /* Test that env var values are case-insensitive (TRUE, True, true all work). */ + process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'TRUE'; + process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = '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', 'lambda:default'); + }); + }); }); diff --git a/lambda-layer/packages/layer/scripts/otel-instrument b/lambda-layer/packages/layer/scripts/otel-instrument index 94ac4b0b..1ebb03c2 100644 --- a/lambda-layer/packages/layer/scripts/otel-instrument +++ b/lambda-layer/packages/layer/scripts/otel-instrument @@ -76,6 +76,11 @@ 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 + # - Disable otel metrics export by default if [ -z "${OTEL_METRICS_EXPORTER}" ]; then export OTEL_METRICS_EXPORTER="none";