Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
5 changes: 5 additions & 0 deletions lambda-layer/packages/layer/scripts/otel-instrument
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down