Skip to content

Commit 363f95e

Browse files
Add Service and Environment dimensions to EMF metrics
When both OTEL_AWS_APPLICATION_SIGNALS_ENABLED and OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED are set to true, EMF metrics will now include Service and Environment as dimensions: - Service: extracted from service.name resource attribute, falls back to "UnknownService" - Environment: extracted from deployment.environment resource attribute, falls back to "lambda:default" - Dimensions are not added if user already set them (case-insensitive) For Lambda, both env vars default to true via otel-instrument wrapper. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent efb4e0a commit 363f95e

File tree

4 files changed

+331
-1
lines changed

4 files changed

+331
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1313

1414
## Unreleased
1515

16+
### Enhancements
17+
18+
- Add Service and Environment dimensions to EMF metrics when Application Signals EMF Export is enabled
19+
([#299](https://github.com/aws-observability/aws-otel-js-instrumentation/pull/299))
20+
1621
## v0.8.0 - 2025-10-08
1722

1823
### Enhancements

aws-distro-opentelemetry-node-autoinstrumentation/src/exporter/aws/metrics/emf-exporter-base.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@ import {
1919
ResourceMetrics,
2020
SumMetricData,
2121
} from '@opentelemetry/sdk-metrics';
22-
import { Resource } from '@opentelemetry/resources';
22+
import { defaultServiceName, Resource } from '@opentelemetry/resources';
23+
import { SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
2324
import { ExportResult, ExportResultCode } from '@opentelemetry/core';
2425
import type { LogEvent } from '@aws-sdk/client-cloudwatch-logs';
26+
import { AwsOpentelemetryConfigurator } from '../../../aws-opentelemetry-configurator';
27+
import { AwsSpanProcessingUtil } from '../../../aws-span-processing-util';
28+
29+
// Constants for Application Signals EMF dimensions
30+
const SERVICE_DIMENSION = 'Service';
31+
const ENVIRONMENT_DIMENSION = 'Environment';
32+
const DEFAULT_ENVIRONMENT = 'lambda:default';
2533

2634
/**
2735
* Intermediate format for metric data before converting to EMF
@@ -188,6 +196,64 @@ export abstract class EMFExporterBase implements PushMetricExporter {
188196
return Object.keys(attributes);
189197
}
190198

199+
/**
200+
* Check if Application Signals EMF export is enabled.
201+
*
202+
* Returns true only if BOTH:
203+
* - OTEL_AWS_APPLICATION_SIGNALS_ENABLED is true
204+
* - OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED is true
205+
*/
206+
private static isApplicationSignalsEmfExportEnabled(): boolean {
207+
const emfExportEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']?.toLowerCase() === 'true';
208+
return AwsOpentelemetryConfigurator.isApplicationSignalsEnabled() && emfExportEnabled;
209+
}
210+
211+
/**
212+
* Check if dimension already exists (case-insensitive match).
213+
*/
214+
private hasDimensionCaseInsensitive(dimensionNames: string[], dimensionToCheck: string): boolean {
215+
const dimensionLower = dimensionToCheck.toLowerCase();
216+
return dimensionNames.some(dim => dim.toLowerCase() === dimensionLower);
217+
}
218+
219+
/**
220+
* Add Service and Environment dimensions if Application Signals EMF export is enabled
221+
* and the dimensions are not already present (case-insensitive check).
222+
*/
223+
private addApplicationSignalsDimensions(dimensionNames: string[], emfLog: EMFLog, resource: Resource): void {
224+
if (!EMFExporterBase.isApplicationSignalsEmfExportEnabled()) {
225+
return;
226+
}
227+
228+
// Add Service dimension if not already set by user
229+
if (!this.hasDimensionCaseInsensitive(dimensionNames, SERVICE_DIMENSION)) {
230+
let serviceName: string = AwsSpanProcessingUtil.UNKNOWN_SERVICE;
231+
if (resource?.attributes) {
232+
const serviceAttr = resource.attributes[SEMRESATTRS_SERVICE_NAME];
233+
if (serviceAttr && serviceAttr !== defaultServiceName()) {
234+
serviceName = String(serviceAttr);
235+
}
236+
}
237+
dimensionNames.unshift(SERVICE_DIMENSION);
238+
emfLog[SERVICE_DIMENSION] = serviceName;
239+
}
240+
241+
// Add Environment dimension if not already set by user
242+
if (!this.hasDimensionCaseInsensitive(dimensionNames, ENVIRONMENT_DIMENSION)) {
243+
let environment: string = DEFAULT_ENVIRONMENT;
244+
if (resource?.attributes) {
245+
const envAttr = resource.attributes[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT];
246+
if (envAttr) {
247+
environment = String(envAttr);
248+
}
249+
}
250+
// Insert after Service if present, otherwise at beginning
251+
const insertIndex = dimensionNames.includes(SERVICE_DIMENSION) ? 1 : 0;
252+
dimensionNames.splice(insertIndex, 0, ENVIRONMENT_DIMENSION);
253+
emfLog[ENVIRONMENT_DIMENSION] = environment;
254+
}
255+
}
256+
191257
/**
192258
* Create a hashable key from attributes for grouping metrics.
193259
*/
@@ -448,6 +514,9 @@ export abstract class EMFExporterBase implements PushMetricExporter {
448514
emfLog[name] = value?.toString() ?? 'undefined';
449515
}
450516

517+
// Add Application Signals dimensions (Service and Environment) if enabled
518+
this.addApplicationSignalsDimensions(dimensionNames, emfLog, resource);
519+
451520
// Add CloudWatch Metrics if we have metrics, include dimensions only if they exist
452521
if (metricDefinitions.length > 0) {
453522
const cloudWatchMetric: CloudWatchMetric = {

aws-distro-opentelemetry-node-autoinstrumentation/test/exporter/aws/metrics/aws-cloudwatch-emf-exporter.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,4 +829,255 @@ describe('TestAWSCloudWatchEMFExporter', () => {
829829
expect(mockSendLogEvent.calledOnce).toBeTruthy();
830830
expect(mockSendLogEvent.calledWith(logEvent)).toBeTruthy();
831831
});
832+
833+
describe('Application Signals EMF Dimensions', () => {
834+
let savedAppSignalsEnabled: string | undefined;
835+
let savedEmfExportEnabled: string | undefined;
836+
837+
beforeEach(() => {
838+
// Save original env vars
839+
savedAppSignalsEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'];
840+
savedEmfExportEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'];
841+
});
842+
843+
afterEach(() => {
844+
// Restore original env vars
845+
if (savedAppSignalsEnabled === undefined) {
846+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'];
847+
} else {
848+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = savedAppSignalsEnabled;
849+
}
850+
if (savedEmfExportEnabled === undefined) {
851+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'];
852+
} else {
853+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = savedEmfExportEnabled;
854+
}
855+
});
856+
857+
it('TestDimensionsNotAddedWhenFeatureDisabled', () => {
858+
/* Test that Service/Environment dimensions are NOT added when feature is disabled. */
859+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'];
860+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'];
861+
862+
const gaugeRecord: MetricRecord = {
863+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
864+
value: 50.0,
865+
};
866+
867+
const resource = new Resource({ 'service.name': 'my-service' });
868+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
869+
870+
// Should NOT have Service or Environment dimensions
871+
expect(result).not.toHaveProperty('Service');
872+
expect(result).not.toHaveProperty('Environment');
873+
const cwMetrics = result._aws.CloudWatchMetrics[0];
874+
expect(cwMetrics.Dimensions![0]).not.toContain('Service');
875+
expect(cwMetrics.Dimensions![0]).not.toContain('Environment');
876+
});
877+
878+
it('TestDimensionsAddedWhenBothEnvVarsEnabled', () => {
879+
/* Test that Service/Environment dimensions ARE added when both env vars are enabled. */
880+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
881+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
882+
883+
const gaugeRecord: MetricRecord = {
884+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
885+
value: 50.0,
886+
};
887+
888+
const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'production' });
889+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
890+
891+
// Should have Service and Environment dimensions
892+
expect(result).toHaveProperty('Service', 'my-service');
893+
expect(result).toHaveProperty('Environment', 'production');
894+
const cwMetrics = result._aws.CloudWatchMetrics[0];
895+
expect(cwMetrics.Dimensions![0]).toContain('Service');
896+
expect(cwMetrics.Dimensions![0]).toContain('Environment');
897+
// Service should be first, Environment second
898+
expect(cwMetrics.Dimensions![0][0]).toEqual('Service');
899+
expect(cwMetrics.Dimensions![0][1]).toEqual('Environment');
900+
});
901+
902+
it('TestDimensionsNotAddedWhenOnlyAppSignalsEnabled', () => {
903+
/* Test that dimensions are NOT added when only APPLICATION_SIGNALS_ENABLED is set. */
904+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
905+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'];
906+
907+
const gaugeRecord: MetricRecord = {
908+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
909+
value: 50.0,
910+
};
911+
912+
const resource = new Resource({ 'service.name': 'my-service' });
913+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
914+
915+
expect(result).not.toHaveProperty('Service');
916+
expect(result).not.toHaveProperty('Environment');
917+
});
918+
919+
it('TestDimensionsNotAddedWhenOnlyEmfExportEnabled', () => {
920+
/* Test that dimensions are NOT added when only EMF_EXPORT_ENABLED is set. */
921+
delete process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'];
922+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
923+
924+
const gaugeRecord: MetricRecord = {
925+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
926+
value: 50.0,
927+
};
928+
929+
const resource = new Resource({ 'service.name': 'my-service' });
930+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
931+
932+
expect(result).not.toHaveProperty('Service');
933+
expect(result).not.toHaveProperty('Environment');
934+
});
935+
936+
it('TestServiceDimensionNotOverwrittenCaseInsensitive', () => {
937+
/* Test that user-set Service dimension (any case) is NOT overwritten. */
938+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
939+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
940+
941+
// User sets 'service' (lowercase) as an attribute
942+
const gaugeRecord: MetricRecord = {
943+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { service: 'user-service' }),
944+
value: 50.0,
945+
};
946+
947+
const resource = new Resource({ 'service.name': 'resource-service' });
948+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
949+
950+
// Should NOT add 'Service' dimension since 'service' already exists
951+
expect(result).not.toHaveProperty('Service');
952+
expect(result).toHaveProperty('service', 'user-service');
953+
// Environment should still be added
954+
expect(result).toHaveProperty('Environment', 'lambda:default');
955+
});
956+
957+
it('TestEnvironmentDimensionNotOverwrittenCaseInsensitive', () => {
958+
/* Test that user-set Environment dimension (any case) is NOT overwritten. */
959+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
960+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
961+
962+
// User sets 'ENVIRONMENT' (uppercase) as an attribute
963+
const gaugeRecord: MetricRecord = {
964+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { ENVIRONMENT: 'user-env' }),
965+
value: 50.0,
966+
};
967+
968+
const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'production' });
969+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
970+
971+
// Should NOT add 'Environment' dimension since 'ENVIRONMENT' already exists
972+
expect(result).not.toHaveProperty('Environment');
973+
expect(result).toHaveProperty('ENVIRONMENT', 'user-env');
974+
// Service should still be added
975+
expect(result).toHaveProperty('Service', 'my-service');
976+
});
977+
978+
it('TestServiceFallbackToUnknownService', () => {
979+
/* Test that Service falls back to UnknownService when resource has no service.name. */
980+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
981+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
982+
983+
const gaugeRecord: MetricRecord = {
984+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
985+
value: 50.0,
986+
};
987+
988+
// Resource without service.name
989+
const resource = new Resource({});
990+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
991+
992+
expect(result).toHaveProperty('Service', 'UnknownService');
993+
});
994+
995+
it('TestServiceFallbackWhenUnknownServicePattern', () => {
996+
/* Test that Service falls back to UnknownService when resource has OTel default service name. */
997+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
998+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
999+
1000+
const gaugeRecord: MetricRecord = {
1001+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
1002+
value: 50.0,
1003+
};
1004+
1005+
// Resource with OTel default service name pattern
1006+
const { defaultServiceName } = require('@opentelemetry/resources');
1007+
const resource = new Resource({ 'service.name': defaultServiceName() });
1008+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
1009+
1010+
expect(result).toHaveProperty('Service', 'UnknownService');
1011+
});
1012+
1013+
it('TestEnvironmentFallbackToLambdaDefault', () => {
1014+
/* Test that Environment falls back to lambda:default when not set in resource. */
1015+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
1016+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
1017+
1018+
const gaugeRecord: MetricRecord = {
1019+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
1020+
value: 50.0,
1021+
};
1022+
1023+
// Resource without deployment.environment
1024+
const resource = new Resource({ 'service.name': 'my-service' });
1025+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
1026+
1027+
expect(result).toHaveProperty('Environment', 'lambda:default');
1028+
});
1029+
1030+
it('TestEnvironmentExtractedFromResource', () => {
1031+
/* Test that Environment is extracted from deployment.environment resource attribute. */
1032+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
1033+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
1034+
1035+
const gaugeRecord: MetricRecord = {
1036+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
1037+
value: 50.0,
1038+
};
1039+
1040+
const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'staging' });
1041+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
1042+
1043+
expect(result).toHaveProperty('Environment', 'staging');
1044+
});
1045+
1046+
it('TestDimensionOrderServiceThenEnvironment', () => {
1047+
/* Test that Service comes before Environment in dimensions array. */
1048+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'true';
1049+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'true';
1050+
1051+
const gaugeRecord: MetricRecord = {
1052+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { existing_dim: 'value' }),
1053+
value: 50.0,
1054+
};
1055+
1056+
const resource = new Resource({ 'service.name': 'my-service', 'deployment.environment': 'prod' });
1057+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
1058+
1059+
const cwMetrics = result._aws.CloudWatchMetrics[0];
1060+
// Dimensions should be: ['Service', 'Environment', 'existing_dim']
1061+
expect(cwMetrics.Dimensions![0][0]).toEqual('Service');
1062+
expect(cwMetrics.Dimensions![0][1]).toEqual('Environment');
1063+
expect(cwMetrics.Dimensions![0][2]).toEqual('existing_dim');
1064+
});
1065+
1066+
it('TestEnvVarsCaseInsensitive', () => {
1067+
/* Test that env var values are case-insensitive (TRUE, True, true all work). */
1068+
process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED'] = 'TRUE';
1069+
process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED'] = 'True';
1070+
1071+
const gaugeRecord: MetricRecord = {
1072+
...exporter['createMetricRecord']('test_metric', 'Count', 'Test', Date.now(), { env: 'test' }),
1073+
value: 50.0,
1074+
};
1075+
1076+
const resource = new Resource({ 'service.name': 'my-service' });
1077+
const result = exporter['createEmfLog']([gaugeRecord], resource, 1234567890);
1078+
1079+
expect(result).toHaveProperty('Service', 'my-service');
1080+
expect(result).toHaveProperty('Environment', 'lambda:default');
1081+
});
1082+
});
8321083
});

lambda-layer/packages/layer/scripts/otel-instrument

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" ]; then
7676
export OTEL_AWS_APPLICATION_SIGNALS_ENABLED="true";
7777
fi
7878

79+
# - Set Application Signals EMF export configuration (adds Service and Environment dimensions)
80+
if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED}" ]; then
81+
export OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED="true";
82+
fi
83+
7984
# - Disable otel metrics export by default
8085
if [ -z "${OTEL_METRICS_EXPORTER}" ]; then
8186
export OTEL_METRICS_EXPORTER="none";

0 commit comments

Comments
 (0)