@aws/aws-distro-opentelemetry-node-autoinstrumentation
Version:
This package provides Amazon Web Services distribution of the OpenTelemetry Node Instrumentation, which allows for auto-instrumentation of NodeJS applications.
536 lines • 24.6 kB
JavaScript
;
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
Object.defineProperty(exports, "__esModule", { value: true });
exports.EMFExporterBase = void 0;
const api_1 = require("@opentelemetry/api");
const sdk_metrics_1 = require("@opentelemetry/sdk-metrics");
const resources_1 = require("@opentelemetry/resources");
const semantic_conventions_1 = require("@opentelemetry/semantic-conventions");
const core_1 = require("@opentelemetry/core");
/**
* Base class for OpenTelemetry metrics exporters that convert to CloudWatch EMF format.
*
* This class contains all the common logic for converting OTel metrics into CloudWatch EMF logs.
* Subclasses need to implement the sendLogEvent method to define where the EMF logs are sent.
*
* https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html
*/
// Constants for Application Signals EMF dimensions
const SERVICE_DIMENSION = 'Service';
const ENVIRONMENT_DIMENSION = 'Environment';
// Platform-specific default environments
const LAMBDA_DEFAULT = 'lambda:default';
const EC2_DEFAULT = 'ec2:default';
const ECS_DEFAULT = 'ecs:default';
const EKS_DEFAULT = 'eks:default';
const UNKNOWN_ENVIRONMENT = 'generic:default';
class EMFExporterBase {
constructor(namespace = 'default', aggregationTemporalitySelector, aggregationSelector) {
// CloudWatch EMF supported units
// Ref: https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html
this.EMF_SUPPORTED_UNITS = new Set([
'Seconds',
'Microseconds',
'Milliseconds',
'Bytes',
'Kilobytes',
'Megabytes',
'Gigabytes',
'Terabytes',
'Bits',
'Kilobits',
'Megabits',
'Gigabits',
'Terabits',
'Percent',
'Count',
'Bytes/Second',
'Kilobytes/Second',
'Megabytes/Second',
'Gigabytes/Second',
'Terabytes/Second',
'Bits/Second',
'Kilobits/Second',
'Megabits/Second',
'Gigabits/Second',
'Terabits/Second',
'Count/Second',
'None',
]);
// OTel to CloudWatch unit mapping
// Ref: opentelemetry-collector-contrib/blob/main/exporter/awsemfexporter/grouped_metric.go#L188
this.UNIT_MAPPING = new Map(Object.entries({
'1': '',
ns: '',
ms: 'Milliseconds',
s: 'Seconds',
us: 'Microseconds',
By: 'Bytes',
bit: 'Bits',
}));
this.namespace = namespace;
if (aggregationTemporalitySelector) {
this.aggregationTemporalitySelector = aggregationTemporalitySelector;
}
else {
this.aggregationTemporalitySelector = (instrumentType) => {
return sdk_metrics_1.AggregationTemporality.DELTA;
};
}
if (aggregationSelector) {
this.aggregationSelector = aggregationSelector;
}
else {
this.aggregationSelector = (instrumentType) => {
switch (instrumentType) {
case sdk_metrics_1.InstrumentType.HISTOGRAM: {
return { type: sdk_metrics_1.AggregationType.EXPONENTIAL_HISTOGRAM };
}
}
return { type: sdk_metrics_1.AggregationType.DEFAULT };
};
}
}
/**
* Get CloudWatch unit from unit in MetricRecord
*/
getUnit(record) {
const unit = record.unit;
if (this.EMF_SUPPORTED_UNITS.has(unit)) {
return unit;
}
return this.UNIT_MAPPING.get(unit);
}
/**
* Extract dimension names from attributes.
* For now, use all attributes as dimensions for the dimension selection logic.
*/
getDimensionNames(attributes) {
return Object.keys(attributes);
}
/**
* Check if Application Signals EMF dimensions should be added.
*
* Returns true by default, unless OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS is explicitly set to 'false'.
* Note: When Agent Observability is enabled, the configurator sets this env var to 'false' by default.
*/
static shouldAddApplicationSignalsDimensions() {
var _a;
return ((_a = process.env['OTEL_METRICS_ADD_APPLICATION_SIGNALS_DIMENSIONS']) !== null && _a !== void 0 ? _a : 'true').toLowerCase() === 'true';
}
/**
* Get the default environment based on the cloud platform.
*/
getDefaultEnvironmentForPlatform(resource) {
if (!(resource === null || resource === void 0 ? void 0 : resource.attributes)) {
return UNKNOWN_ENVIRONMENT;
}
const cloudPlatform = resource.attributes[semantic_conventions_1.SEMRESATTRS_CLOUD_PLATFORM];
if (typeof cloudPlatform === 'string') {
switch (cloudPlatform) {
case semantic_conventions_1.CLOUDPLATFORMVALUES_AWS_LAMBDA:
return LAMBDA_DEFAULT;
case semantic_conventions_1.CLOUDPLATFORMVALUES_AWS_EC2:
return EC2_DEFAULT;
case semantic_conventions_1.CLOUDPLATFORMVALUES_AWS_ECS:
return ECS_DEFAULT;
case semantic_conventions_1.CLOUDPLATFORMVALUES_AWS_EKS:
return EKS_DEFAULT;
}
}
return UNKNOWN_ENVIRONMENT;
}
/**
* Check if dimension already exists (case-insensitive match).
*/
hasDimensionCaseInsensitive(dimensionNames, dimensionToCheck) {
const dimensionLower = dimensionToCheck.toLowerCase();
return dimensionNames.some(dim => dim.toLowerCase() === dimensionLower);
}
/**
* Add Service and Environment dimensions if Application Signals dimensions are enabled
* and the dimensions are not already present (case-insensitive check).
*/
addApplicationSignalsDimensions(dimensionNames, emfLog, resource) {
if (!EMFExporterBase.shouldAddApplicationSignalsDimensions()) {
return;
}
// Add Service dimension if not already set by user
if (!this.hasDimensionCaseInsensitive(dimensionNames, SERVICE_DIMENSION)) {
let serviceName = 'UnknownService';
if (resource === null || resource === void 0 ? void 0 : resource.attributes) {
const serviceAttr = resource.attributes[semantic_conventions_1.SEMRESATTRS_SERVICE_NAME];
if (serviceAttr && serviceAttr !== (0, resources_1.defaultServiceName)()) {
serviceName = String(serviceAttr);
}
}
dimensionNames.push(SERVICE_DIMENSION);
emfLog[SERVICE_DIMENSION] = serviceName;
}
// Add Environment dimension if not already set by user
if (!this.hasDimensionCaseInsensitive(dimensionNames, ENVIRONMENT_DIMENSION)) {
let environment;
if (resource === null || resource === void 0 ? void 0 : resource.attributes) {
// First check deployment.environment.name (newer semantic convention)
const envNameAttr = resource.attributes['deployment.environment.name'];
if (envNameAttr) {
environment = String(envNameAttr);
}
// Then check deployment.environment (older semantic convention)
if (!environment) {
const envAttr = resource.attributes[semantic_conventions_1.SEMRESATTRS_DEPLOYMENT_ENVIRONMENT];
if (envAttr) {
environment = String(envAttr);
}
}
}
// Fall back to platform-specific default
if (!environment) {
environment = this.getDefaultEnvironmentForPlatform(resource);
}
dimensionNames.push(ENVIRONMENT_DIMENSION);
emfLog[ENVIRONMENT_DIMENSION] = environment;
}
}
/**
* Create a hashable key from attributes for grouping metrics.
*/
getAttributesKey(attributes) {
// Sort the attributes to ensure consistent keys
const sortedAttrs = Object.entries(attributes).sort();
// Create a string representation of the attributes
return sortedAttrs.toString();
}
/**
* Normalize an OpenTelemetry timestamp to milliseconds for CloudWatch.
*/
normalizeTimestamp(hrTime) {
// Convert from second and nanoseconds to milliseconds
const secondsToMillis = hrTime[0] * 1000;
const nanosToMillis = Math.floor(hrTime[1] / 1000000);
return secondsToMillis + nanosToMillis;
}
/**
* Create a base metric record with instrument information.
*/
createMetricRecord(metricName, metricUnit, metricDescription, timestamp, attributes) {
return {
name: metricName,
unit: metricUnit,
description: metricDescription,
timestamp,
attributes,
};
}
/**
* Convert a Gauge or Sum metric datapoint to a metric record.
*/
convertGaugeAndSum(metric, dataPoint) {
const timestampMs = this.normalizeTimestamp(dataPoint.endTime);
const record = this.createMetricRecord(metric.descriptor.name, metric.descriptor.unit, metric.descriptor.description, timestampMs, dataPoint.attributes);
record.value = dataPoint.value;
return record;
}
/**
* Convert a Histogram metric datapoint to a metric record.
*
* https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/awsemfexporter/datapoint.go#L87
*/
convertHistogram(metric, dataPoint) {
var _a, _b, _c;
const timestampMs = this.normalizeTimestamp(dataPoint.endTime);
const record = this.createMetricRecord(metric.descriptor.name, metric.descriptor.unit, metric.descriptor.description, timestampMs, dataPoint.attributes);
record.histogramData = {
Count: dataPoint.value.count,
Sum: (_a = dataPoint.value.sum) !== null && _a !== void 0 ? _a : 0,
Min: (_b = dataPoint.value.min) !== null && _b !== void 0 ? _b : 0,
Max: (_c = dataPoint.value.max) !== null && _c !== void 0 ? _c : 0,
};
return record;
}
/**
* Convert an ExponentialHistogram metric datapoint to a metric record.
*
* This function follows the logic of CalculateDeltaDatapoints in the Go implementation,
* converting exponential buckets to their midpoint values.
* Ref: https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/22626
*/
convertExpHistogram(metric, dataPoint) {
var _a, _b, _c, _d, _e, _f, _g;
// Initialize arrays for values and counts
const arrayValues = [];
const arrayCounts = [];
const scale = dataPoint.value.scale;
const base = Math.pow(2, Math.pow(2, -scale));
// Process positive buckets
if ((_b = (_a = dataPoint.value) === null || _a === void 0 ? void 0 : _a.positive) === null || _b === void 0 ? void 0 : _b.bucketCounts) {
const positiveOffset = dataPoint.value.positive.offset;
const positiveBucketCounts = dataPoint.value.positive.bucketCounts;
let bucketBegin = 0;
let bucketEnd = 0;
for (const [i, count] of positiveBucketCounts.entries()) {
const index = i + positiveOffset;
if (bucketBegin === 0) {
bucketBegin = Math.pow(base, index);
}
else {
bucketBegin = bucketEnd;
}
bucketEnd = Math.pow(base, index + 1);
// Calculate midpoint value of the bucket
const metricVal = (bucketBegin + bucketEnd) / 2;
// Only include buckets with positive counts
if (count > 0) {
arrayValues.push(metricVal);
arrayCounts.push(count);
}
}
}
// Process zero bucket
const zeroCount = dataPoint.value.zeroCount;
if (zeroCount > 0) {
arrayValues.push(0);
arrayCounts.push(zeroCount);
}
// Process negative buckets
if ((_d = (_c = dataPoint.value) === null || _c === void 0 ? void 0 : _c.negative) === null || _d === void 0 ? void 0 : _d.bucketCounts) {
const negativeOffset = dataPoint.value.negative.offset;
const negativeBucketCounts = dataPoint.value.negative.bucketCounts;
let bucketBegin = 0;
let bucketEnd = 0;
for (const [i, count] of negativeBucketCounts.entries()) {
const index = i + negativeOffset;
if (bucketEnd === 0) {
bucketEnd = -Math.pow(base, index);
}
else {
bucketEnd = bucketBegin;
}
bucketBegin = -Math.pow(base, index + 1);
// Calculate midpoint value of the bucket
const metricVal = (bucketBegin + bucketEnd) / 2;
// Only include buckets with positive counts
if (count > 0) {
arrayValues.push(metricVal);
arrayCounts.push(count);
}
}
}
const timestampMs = this.normalizeTimestamp(dataPoint.endTime);
const record = this.createMetricRecord(metric.descriptor.name, metric.descriptor.unit, metric.descriptor.description, timestampMs, dataPoint.attributes);
// Set the histogram data in the format expected by CloudWatch EMF
record.expHistogramData = {
Values: arrayValues,
Counts: arrayCounts,
Count: dataPoint.value.count,
Sum: (_e = dataPoint.value.sum) !== null && _e !== void 0 ? _e : 0,
Max: (_f = dataPoint.value.max) !== null && _f !== void 0 ? _f : 0,
Min: (_g = dataPoint.value.min) !== null && _g !== void 0 ? _g : 0,
};
return record;
}
/**
* Create EMF log from metric records.
*
* Since metricRecords is already grouped by attributes, this function
* creates a single EMF log for all records.
*/
createEmfLog(metricRecords, resource, timestamp = undefined) {
var _a, _b;
// Start with base structure and latest EMF version schema
// opentelemetry-collector-contrib/blob/main/exporter/awsemfexporter/metric_translator.go#L414
const emfLog = {
_aws: {
Timestamp: timestamp || Date.now(),
CloudWatchMetrics: [],
},
Version: '1',
};
// Add resource attributes to EMF log but not as dimensions
// OTel collector EMF Exporter has a resource_to_telemetry_conversion flag that will convert resource attributes
// as regular metric attributes(potential dimensions). However, for this SDK EMF implementation,
// we align with the OpenTelemetry concept that all metric attributes are treated as dimensions.
// And have resource attributes as just additional metadata in EMF, added otel.resource as prefix to distinguish.
if (resource && resource.attributes) {
for (const [key, value] of Object.entries(resource.attributes)) {
emfLog[`otel.resource.${key}`] = (_a = value === null || value === void 0 ? void 0 : value.toString()) !== null && _a !== void 0 ? _a : 'undefined';
}
}
// Initialize collections for dimensions and metrics
const metricDefinitions = [];
// Collect attributes from all records (they should be the same for all records in the group)
// Only collect once from the first record and apply to all records
const allAttributes = metricRecords.length > 0 ? metricRecords[0].attributes : {};
// Process each metric record
for (const record of metricRecords) {
const metricName = record.name;
if (!metricName) {
continue;
}
if (record.expHistogramData) {
// Base2 Exponential Histogram
emfLog[metricName] = record.expHistogramData;
}
else if (record.histogramData) {
// Regular Histogram metrics
emfLog[metricName] = record.histogramData;
}
else if (record.value !== undefined) {
// Gauge, Sum, and other aggregations
emfLog[metricName] = record.value;
}
else {
api_1.diag.debug(`Skipping metric ${metricName} as it does not have valid metric value`);
continue;
}
const metricData = {
Name: metricName,
};
const unit = this.getUnit(record);
if (unit) {
metricData.Unit = unit;
}
metricDefinitions.push(metricData);
}
const dimensionNames = this.getDimensionNames(allAttributes);
// Add attribute values to the root of the EMF log
for (const [name, value] of Object.entries(allAttributes)) {
emfLog[name] = (_b = value === null || value === void 0 ? void 0 : value.toString()) !== null && _b !== void 0 ? _b : 'undefined';
}
// Add Application Signals dimensions (Service and Environment) if enabled
this.addApplicationSignalsDimensions(dimensionNames, emfLog, resource);
// Add CloudWatch Metrics if we have metrics, include dimensions only if they exist
if (metricDefinitions.length > 0) {
const cloudWatchMetric = {
Namespace: this.namespace,
Metrics: metricDefinitions,
};
if (dimensionNames.length > 0) {
cloudWatchMetric.Dimensions = [dimensionNames];
}
emfLog._aws.CloudWatchMetrics.push(cloudWatchMetric);
}
return emfLog;
}
/**
* Group metric record by attributes and timestamp.
*
* @param record The metric record
* @param timestampMs The timestamp in milliseconds
* @returns {[string, number]} Values for the key to group metrics
*/
groupByAttributesAndTimestamp(record) {
// Create a key for grouping based on attributes
const attrsKey = this.getAttributesKey(record.attributes);
return [attrsKey, record.timestamp];
}
/**
* Method to handle safely pushing a MetricRecord into a Map of a Map of a list of MetricRecords
*
* @param groupedMetrics
* @param groupAttribute
* @param groupTimestamp
* @param record
*/
pushMetricRecordIntoGroupedMetrics(groupedMetrics, groupAttribute, groupTimestamp, record) {
let metricsGroupedByAttribute = groupedMetrics.get(groupAttribute);
if (!metricsGroupedByAttribute) {
metricsGroupedByAttribute = new Map();
groupedMetrics.set(groupAttribute, metricsGroupedByAttribute);
}
let metricsGroupedByAttributeAndTimestamp = metricsGroupedByAttribute.get(groupTimestamp);
if (!metricsGroupedByAttributeAndTimestamp) {
metricsGroupedByAttributeAndTimestamp = [];
metricsGroupedByAttribute.set(groupTimestamp, metricsGroupedByAttributeAndTimestamp);
}
metricsGroupedByAttributeAndTimestamp.push(record);
}
/**
* Export metrics as EMF logs.
* Groups metrics by attributes and timestamp before creating EMF logs.
*
* @param resourceMetrics Resource Metrics data containing scope metrics
* @param resultCallback callback for when the export has completed
* @returns {Promise<void>}
*/
async export(resourceMetrics, resultCallback) {
try {
if (!resourceMetrics) {
resultCallback({ code: core_1.ExportResultCode.SUCCESS });
return;
}
// Process all metrics from resource metrics their scope metrics
// The resource is now part of each resourceMetrics object
const resource = resourceMetrics.resource;
for (const scopeMetrics of resourceMetrics.scopeMetrics) {
// Map of maps to group metrics by attributes and timestamp
// Keys: (attributesKey, timestampMs)
// Value: list of metric records
const groupedMetrics = new Map();
// Process all metrics in this scope
for (const metric of scopeMetrics.metrics) {
// Convert metrics to a format compatible with createEmfLog
// Process metric.dataPoints for different metric types
if (metric.dataPointType === sdk_metrics_1.DataPointType.GAUGE || metric.dataPointType === sdk_metrics_1.DataPointType.SUM) {
for (const dataPoint of metric.dataPoints) {
const record = this.convertGaugeAndSum(metric, dataPoint);
const [groupAttribute, groupTimestamp] = this.groupByAttributesAndTimestamp(record);
this.pushMetricRecordIntoGroupedMetrics(groupedMetrics, groupAttribute, groupTimestamp, record);
}
}
else if (metric.dataPointType === sdk_metrics_1.DataPointType.HISTOGRAM) {
for (const dataPoint of metric.dataPoints) {
const record = this.convertHistogram(metric, dataPoint);
const [groupAttribute, groupTimestamp] = this.groupByAttributesAndTimestamp(record);
this.pushMetricRecordIntoGroupedMetrics(groupedMetrics, groupAttribute, groupTimestamp, record);
}
}
else if (metric.dataPointType === sdk_metrics_1.DataPointType.EXPONENTIAL_HISTOGRAM) {
for (const dataPoint of metric.dataPoints) {
const record = this.convertExpHistogram(metric, dataPoint);
const [groupAttribute, groupTimestamp] = this.groupByAttributesAndTimestamp(record);
this.pushMetricRecordIntoGroupedMetrics(groupedMetrics, groupAttribute, groupTimestamp, record);
}
}
else {
// This else block should never run, all metric types are accounted for above
api_1.diag.debug(`Unsupported Metric Type in metric: ${metric}`);
}
}
// Now process each group separately to create one EMF log per group
for (const [_, metricsRecordsGroupedByTimestamp] of groupedMetrics) {
for (const [timestampMs, metricRecords] of metricsRecordsGroupedByTimestamp) {
if (metricRecords) {
// Create and send EMF log for this batch of metrics
// Convert to JSON
const logEvent = {
message: JSON.stringify(this.createEmfLog(metricRecords, resource, Number(timestampMs))),
timestamp: timestampMs,
};
// Send log events to the destination
await this.sendLogEvent(logEvent);
}
}
}
}
resultCallback({ code: core_1.ExportResultCode.SUCCESS });
}
catch (e) {
api_1.diag.error(`Failed to export metrics: ${e}`);
const exportResult = { code: core_1.ExportResultCode.FAILED };
if (e instanceof Error) {
exportResult.error = e;
}
resultCallback(exportResult);
}
}
selectAggregationTemporality(instrumentType) {
return this.aggregationTemporalitySelector(instrumentType);
}
selectAggregation(instrumentType) {
return this.aggregationSelector(instrumentType);
}
}
exports.EMFExporterBase = EMFExporterBase;
//# sourceMappingURL=emf-exporter-base.js.map