Skip to content

Commit 83a1c85

Browse files
authored
EMF Exporter implementation with Gauge, Sum, and Histogram support (#206)
*Issue #, if available:* JS Equivalent of: - aws-observability/aws-otel-python-instrumentation#382 - aws-observability/aws-otel-python-instrumentation#409 - aws-observability/aws-otel-python-instrumentation#410 *Description of changes:* By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent ad3e0d3 commit 83a1c85

File tree

10 files changed

+4948
-3807
lines changed

10 files changed

+4948
-3807
lines changed

aws-distro-opentelemetry-node-autoinstrumentation/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,15 @@
9494
"rimraf": "5.0.5",
9595
"sinon": "15.2.0",
9696
"ts-mocha": "10.0.0",
97-
"typescript": "4.4.4"
97+
"typescript": "4.9.5"
9898
},
9999
"dependencies": {
100+
"@aws-sdk/client-cloudwatch-logs": "3.621.0",
100101
"@opentelemetry/api": "1.9.0",
101102
"@opentelemetry/auto-configuration-propagators": "0.3.2",
102103
"@opentelemetry/auto-instrumentations-node": "0.56.0",
103104
"@opentelemetry/api-events": "0.57.1",
104105
"@opentelemetry/baggage-span-processor": "0.3.1",
105-
"@opentelemetry/sdk-events": "0.57.1",
106-
"@opentelemetry/sdk-logs": "0.57.1",
107106
"@opentelemetry/core": "1.30.1",
108107
"@opentelemetry/exporter-metrics-otlp-grpc": "0.57.1",
109108
"@opentelemetry/exporter-metrics-otlp-http": "0.57.1",
@@ -119,10 +118,11 @@
119118
"@opentelemetry/propagator-aws-xray": "1.26.2",
120119
"@opentelemetry/resource-detector-aws": "1.12.0",
121120
"@opentelemetry/resources": "1.30.1",
121+
"@opentelemetry/sdk-events": "0.57.1",
122+
"@opentelemetry/sdk-logs": "0.57.1",
122123
"@opentelemetry/sdk-metrics": "1.30.1",
123124
"@opentelemetry/sdk-node": "0.57.1",
124125
"@opentelemetry/sdk-trace-base": "1.30.1",
125-
"@opentelemetry/sdk-logs": "0.57.1",
126126
"@opentelemetry/semantic-conventions": "1.28.0"
127127
},
128128
"files": [

aws-distro-opentelemetry-node-autoinstrumentation/src/aws-opentelemetry-configurator.ts

Lines changed: 123 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,15 @@ import { OTLPUdpSpanExporter } from './otlp-udp-exporter';
7373
import { AwsXRayRemoteSampler } from './sampler/aws-xray-remote-sampler';
7474
// This file is generated via `npm run compile`
7575
import { LIB_VERSION } from './version';
76+
import { AWSCloudWatchEMFExporter } from './exporter/aws/metrics/aws-cloudwatch-emf-exporter';
7677
import { OTLPAwsLogExporter } from './exporter/otlp/aws/logs/otlp-aws-log-exporter';
77-
7878
import { isAgentObservabilityEnabled } from './utils';
7979
import { BaggageSpanProcessor } from '@opentelemetry/baggage-span-processor';
8080
import { logs } from '@opentelemetry/api-logs';
8181

8282
const AWS_TRACES_OTLP_ENDPOINT_PATTERN = '^https://xray\\.([a-z0-9-]+)\\.amazonaws\\.com/v1/traces$';
8383
const AWS_LOGS_OTLP_ENDPOINT_PATTERN = '^https://logs\\.([a-z0-9-]+)\\.amazonaws\\.com/v1/logs$';
8484

85-
const AWS_OTLP_LOGS_GROUP_HEADER = 'x-aws-log-group';
86-
const AWS_OTLP_LOGS_STREAM_HEADER = 'x-aws-log-stream';
87-
8885
const APPLICATION_SIGNALS_ENABLED_CONFIG: string = 'OTEL_AWS_APPLICATION_SIGNALS_ENABLED';
8986
const APPLICATION_SIGNALS_EXPORTER_ENDPOINT_CONFIG: string = 'OTEL_AWS_APPLICATION_SIGNALS_EXPORTER_ENDPOINT';
9087
const METRIC_EXPORT_INTERVAL_CONFIG: string = 'OTEL_METRIC_EXPORT_INTERVAL';
@@ -99,6 +96,17 @@ const FORMAT_OTEL_UNSAMPLED_TRACES_BINARY_PREFIX = 'T1U';
9996
const LAMBDA_SPAN_EXPORT_BATCH_SIZE = 10;
10097
export const LAMBDA_APPLICATION_SIGNALS_REMOTE_ENVIRONMENT: string = 'LAMBDA_APPLICATION_SIGNALS_REMOTE_ENVIRONMENT';
10198

99+
const AWS_OTLP_LOGS_GROUP_HEADER = 'x-aws-log-group';
100+
const AWS_OTLP_LOGS_STREAM_HEADER = 'x-aws-log-stream';
101+
const AWS_EMF_METRICS_NAMESPACE = 'x-aws-metric-namespace';
102+
103+
interface OtlpLogHeaderSetting {
104+
logGroup?: string;
105+
logStream?: string;
106+
namespace?: string;
107+
isValid: boolean;
108+
}
109+
102110
/**
103111
* Aws Application Signals Config Provider creates a configuration object that can be provided to
104112
* the OTel NodeJS SDK for Auto Instrumentation with Application Signals Functionality.
@@ -120,6 +128,7 @@ export class AwsOpentelemetryConfigurator {
120128
private spanProcessors: SpanProcessor[];
121129
private logRecordProcessors: LogRecordProcessor[];
122130
private propagator: TextMapPropagator;
131+
private metricReader: PeriodicExportingMetricReader | undefined;
123132

124133
/**
125134
* The constructor will setup the AwsOpentelemetryConfigurator object to be able to provide a
@@ -204,6 +213,9 @@ export class AwsOpentelemetryConfigurator {
204213
this.spanProcessors = awsSpanProcessorProvider.getSpanProcessors();
205214
this.logRecordProcessors = AwsLoggerProcessorProvider.getlogRecordProcessors();
206215
AwsOpentelemetryConfigurator.customizeSpanProcessors(this.spanProcessors, this.resource);
216+
217+
const isEmfEnabled = checkEmfExporterEnabled();
218+
this.customizeMetricReader(isEmfEnabled);
207219
}
208220

209221
private customizeVersions(autoResource: Resource): Resource {
@@ -236,6 +248,10 @@ export class AwsOpentelemetryConfigurator {
236248
textMapPropagator: this.propagator,
237249
};
238250

251+
if (this.metricReader) {
252+
config.metricReader = this.metricReader;
253+
}
254+
239255
return config;
240256
}
241257

@@ -248,6 +264,20 @@ export class AwsOpentelemetryConfigurator {
248264
return isApplicationSignalsEnabled.toLowerCase() === 'true';
249265
}
250266

267+
static geMetricExportInterval(): number {
268+
let exportIntervalMillis: number = Number(process.env[METRIC_EXPORT_INTERVAL_CONFIG]);
269+
diag.debug(`AWS Application Signals Metrics export interval: ${exportIntervalMillis}`);
270+
271+
// Cap export interval to 60 seconds. This is currently required for metrics-trace correlation to work correctly.
272+
if (isNaN(exportIntervalMillis) || exportIntervalMillis.valueOf() > DEFAULT_METRIC_EXPORT_INTERVAL_MILLIS) {
273+
exportIntervalMillis = DEFAULT_METRIC_EXPORT_INTERVAL_MILLIS;
274+
275+
diag.info(`AWS Application Signals metrics export interval capped to ${exportIntervalMillis}`);
276+
}
277+
278+
return exportIntervalMillis;
279+
}
280+
251281
static exportUnsampledSpanForAgentObservability(spanProcessors: SpanProcessor[], resource: Resource): void {
252282
if (!isAgentObservabilityEnabled()) {
253283
return;
@@ -296,22 +326,13 @@ export class AwsOpentelemetryConfigurator {
296326

297327
diag.info('AWS Application Signals enabled.');
298328

299-
let exportIntervalMillis: number = Number(process.env[METRIC_EXPORT_INTERVAL_CONFIG]);
300-
diag.debug(`AWS Application Signals Metrics export interval: ${exportIntervalMillis}`);
301-
302-
if (isNaN(exportIntervalMillis) || exportIntervalMillis.valueOf() > DEFAULT_METRIC_EXPORT_INTERVAL_MILLIS) {
303-
exportIntervalMillis = DEFAULT_METRIC_EXPORT_INTERVAL_MILLIS;
304-
305-
diag.info(`AWS Application Signals metrics export interval capped to ${exportIntervalMillis}`);
306-
}
307-
308329
spanProcessors.push(AttributePropagatingSpanProcessorBuilder.create().build());
309330

310331
const applicationSignalsMetricExporter: PushMetricExporter =
311332
ApplicationSignalsExporterProvider.Instance.createExporter();
312333
const periodicExportingMetricReader: PeriodicExportingMetricReader = new PeriodicExportingMetricReader({
313334
exporter: applicationSignalsMetricExporter,
314-
exportIntervalMillis: exportIntervalMillis,
335+
exportIntervalMillis: AwsOpentelemetryConfigurator.geMetricExportInterval(),
315336
});
316337

317338
// Register BatchUnsampledSpanProcessor to export unsampled traces in Lambda
@@ -347,6 +368,18 @@ export class AwsOpentelemetryConfigurator {
347368
}
348369
}
349370

371+
private customizeMetricReader(isEmfEnabled: boolean) {
372+
if (isEmfEnabled) {
373+
const emfExporter = createEmfExporter();
374+
if (emfExporter) {
375+
const periodicExportingMetricReader = new PeriodicExportingMetricReader({
376+
exporter: emfExporter,
377+
});
378+
this.metricReader = periodicExportingMetricReader;
379+
}
380+
}
381+
}
382+
350383
static customizeSampler(sampler: Sampler): Sampler {
351384
if (AwsOpentelemetryConfigurator.isApplicationSignalsEnabled()) {
352385
return AlwaysRecordSampler.create(sampler);
@@ -517,7 +550,7 @@ export class AwsLoggerProcessorProvider {
517550
if (
518551
otlpExporterLogsEndpoint &&
519552
isAwsOtlpEndpoint(otlpExporterLogsEndpoint, 'logs') &&
520-
validateLogsHeaders()
553+
validateAndFetchLogsHeader().isValid
521554
) {
522555
diag.debug('Detected CloudWatch Logs OTLP endpoint. Switching exporter to OTLPAwsLogExporter');
523556
exporters.push(
@@ -538,7 +571,7 @@ export class AwsLoggerProcessorProvider {
538571
if (
539572
otlpExporterLogsEndpoint &&
540573
isAwsOtlpEndpoint(otlpExporterLogsEndpoint, 'logs') &&
541-
validateLogsHeaders()
574+
validateAndFetchLogsHeader().isValid
542575
) {
543576
diag.debug('Detected CloudWatch Logs OTLP endpoint. Switching exporter to OTLPAwsLogExporter');
544577
exporters.push(
@@ -857,6 +890,8 @@ function getSamplerProbabilityFromEnv(environment: Required<ENVIRONMENT>): numbe
857890
return probability;
858891
}
859892

893+
// END The OpenTelemetry Authors code
894+
860895
function getSpanExportBatchSize() {
861896
if (isLambdaEnvironment()) {
862897
return LAMBDA_SPAN_EXPORT_BATCH_SIZE;
@@ -880,8 +915,7 @@ function getXrayDaemonEndpoint() {
880915
/**
881916
* Determines if the given endpoint is either the AWS OTLP Traces or Logs endpoint.
882917
*/
883-
884-
function isAwsOtlpEndpoint(otlpEndpoint: string, service: string): boolean {
918+
export function isAwsOtlpEndpoint(otlpEndpoint: string, service: string): boolean {
885919
let pattern = '';
886920
if (service === 'xray') {
887921
pattern = AWS_TRACES_OTLP_ENDPOINT_PATTERN;
@@ -898,40 +932,98 @@ function isAwsOtlpEndpoint(otlpEndpoint: string, service: string): boolean {
898932
* Checks if x-aws-log-group and x-aws-log-stream are present in the headers in order to send logs to
899933
* AWS OTLP Logs endpoint.
900934
*/
901-
function validateLogsHeaders() {
902-
const logsHeaders = process.env['OTEL_EXPORTER_OTLP_LOGS_HEADERS'];
935+
export function validateAndFetchLogsHeader(): OtlpLogHeaderSetting {
936+
const logHeaders = process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS;
903937

904-
if (!logsHeaders) {
938+
if (!logHeaders) {
905939
diag.warn(
906940
'Missing required configuration: The environment variable OTEL_EXPORTER_OTLP_LOGS_HEADERS must be set with ' +
907941
`required headers ${AWS_OTLP_LOGS_GROUP_HEADER} and ${AWS_OTLP_LOGS_STREAM_HEADER}. ` +
908942
`Example: OTEL_EXPORTER_OTLP_LOGS_HEADERS="${AWS_OTLP_LOGS_GROUP_HEADER}=my-log-group,${AWS_OTLP_LOGS_STREAM_HEADER}=my-log-stream"`
909943
);
910-
return false;
944+
return {
945+
logGroup: '',
946+
logStream: '',
947+
namespace: '',
948+
isValid: false,
949+
};
911950
}
912951

913-
let hasLogGroup = false;
914-
let hasLogStream = false;
952+
let logGroup: string | undefined = undefined;
953+
let logStream: string | undefined = undefined;
954+
let namespace: string | undefined = undefined;
955+
let filteredLogHeadersCount: number = 0;
956+
957+
for (const pair of logHeaders.split(',')) {
958+
const splitIndex = pair.indexOf('=');
959+
if (splitIndex > -1) {
960+
const key = pair.substring(0, splitIndex);
961+
const value = pair.substring(splitIndex + 1);
915962

916-
for (const pair of logsHeaders.split(',')) {
917-
if (pair.includes('=')) {
918-
const [key, value] = pair.split('=', 2);
919963
if (key === AWS_OTLP_LOGS_GROUP_HEADER && value) {
920-
hasLogGroup = true;
964+
logGroup = value;
965+
filteredLogHeadersCount++;
921966
} else if (key === AWS_OTLP_LOGS_STREAM_HEADER && value) {
922-
hasLogStream = true;
967+
logStream = value;
968+
filteredLogHeadersCount++;
969+
} else if (key === AWS_EMF_METRICS_NAMESPACE && value) {
970+
namespace = value;
923971
}
924972
}
925973
}
926974

927-
if (!hasLogGroup || !hasLogStream) {
975+
const isValid = filteredLogHeadersCount === 2 && !!logGroup && !!logStream;
976+
if (!isValid) {
928977
diag.warn(
929978
'Incomplete configuration: Please configure the environment variable OTEL_EXPORTER_OTLP_LOGS_HEADERS ' +
930979
`to have values for ${AWS_OTLP_LOGS_GROUP_HEADER} and ${AWS_OTLP_LOGS_STREAM_HEADER}`
931980
);
981+
}
982+
983+
return {
984+
logGroup: logGroup,
985+
logStream: logStream,
986+
namespace: namespace,
987+
isValid: isValid,
988+
};
989+
}
990+
991+
export function checkEmfExporterEnabled(): boolean {
992+
const exporterValue = process.env.OTEL_METRICS_EXPORTER;
993+
if (exporterValue === undefined) {
994+
return false;
995+
}
996+
997+
const exporters = exporterValue.split(',').map(exporter => exporter.trim());
998+
999+
const index = exporters.indexOf('awsemf');
1000+
if (index === -1) {
9321001
return false;
9331002
}
1003+
1004+
exporters.splice(index, 1);
1005+
1006+
const newValue = exporters ? exporters.join(',') : undefined;
1007+
1008+
if (typeof newValue === 'string' && newValue !== '') {
1009+
process.env.OTEL_METRICS_EXPORTER = newValue;
1010+
} else {
1011+
delete process.env.OTEL_METRICS_EXPORTER;
1012+
}
1013+
9341014
return true;
9351015
}
9361016

937-
// END The OpenTelemetry Authors code
1017+
export function createEmfExporter(): AWSCloudWatchEMFExporter | undefined {
1018+
const headersResult = validateAndFetchLogsHeader();
1019+
if (!headersResult.isValid) {
1020+
return undefined;
1021+
}
1022+
1023+
// If headersResult.isValid is true, then headersResult.logGroup and headersResult.logStream are guaranteed to be strings
1024+
return new AWSCloudWatchEMFExporter(
1025+
headersResult.namespace,
1026+
headersResult.logGroup as string,
1027+
headersResult.logStream as string
1028+
);
1029+
}

0 commit comments

Comments
 (0)