Skip to content

Commit 3e9cd4a

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 3e9cd4a

File tree

3 files changed

+320
-1
lines changed

3 files changed

+320
-1
lines changed

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ 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';
2526

@@ -188,6 +189,65 @@ export abstract class EMFExporterBase implements PushMetricExporter {
188189
return Object.keys(attributes);
189190
}
190191

192+
/**
193+
* Check if Application Signals EMF export is enabled.
194+
*
195+
* Returns true only if BOTH:
196+
* - OTEL_AWS_APPLICATION_SIGNALS_ENABLED is true
197+
* - OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED is true
198+
*/
199+
private static isApplicationSignalsEmfExportEnabled(): boolean {
200+
const appSignalsEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_ENABLED']?.toLowerCase() === 'true';
201+
const emfExportEnabled = process.env['OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED']?.toLowerCase() === 'true';
202+
return appSignalsEnabled && emfExportEnabled;
203+
}
204+
205+
/**
206+
* Check if dimension already exists (case-insensitive match).
207+
*/
208+
private hasDimensionCaseInsensitive(dimensionNames: string[], dimensionToCheck: string): boolean {
209+
const dimensionLower = dimensionToCheck.toLowerCase();
210+
return dimensionNames.some(dim => dim.toLowerCase() === dimensionLower);
211+
}
212+
213+
/**
214+
* Add Service and Environment dimensions if Application Signals EMF export is enabled
215+
* and the dimensions are not already present (case-insensitive check).
216+
*/
217+
private addApplicationSignalsDimensions(dimensionNames: string[], emfLog: EMFLog, resource: Resource): void {
218+
if (!EMFExporterBase.isApplicationSignalsEmfExportEnabled()) {
219+
return;
220+
}
221+
222+
// Add Service dimension if not already set by user
223+
if (!this.hasDimensionCaseInsensitive(dimensionNames, 'Service')) {
224+
let serviceName: string = 'UnknownService';
225+
if (resource?.attributes) {
226+
const serviceAttr = resource.attributes[SEMRESATTRS_SERVICE_NAME];
227+
if (serviceAttr && serviceAttr !== defaultServiceName()) {
228+
serviceName = String(serviceAttr);
229+
}
230+
}
231+
dimensionNames.unshift('Service');
232+
emfLog['Service'] = serviceName;
233+
}
234+
235+
// Add Environment dimension if not already set by user
236+
if (!this.hasDimensionCaseInsensitive(dimensionNames, 'Environment')) {
237+
let environment: string = 'lambda:default';
238+
if (resource?.attributes) {
239+
const envAttr = resource.attributes[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT];
240+
if (envAttr) {
241+
environment = String(envAttr);
242+
}
243+
}
244+
// Insert after Service if present, otherwise at beginning
245+
const insertIndex = dimensionNames.includes('Service') ? 1 : 0;
246+
dimensionNames.splice(insertIndex, 0, 'Environment');
247+
emfLog['Environment'] = environment;
248+
}
249+
}
250+
191251
/**
192252
* Create a hashable key from attributes for grouping metrics.
193253
*/
@@ -443,6 +503,9 @@ export abstract class EMFExporterBase implements PushMetricExporter {
443503

444504
const dimensionNames = this.getDimensionNames(allAttributes);
445505

506+
// Add Application Signals dimensions (Service and Environment) if enabled
507+
this.addApplicationSignalsDimensions(dimensionNames, emfLog, resource);
508+
446509
// Add attribute values to the root of the EMF log
447510
for (const [name, value] of Object.entries(allAttributes)) {
448511
emfLog[name] = value?.toString() ?? 'undefined';

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)