Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement experimental AWS ECS resource attributes #1083

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,26 @@
*/

import { diag } from '@opentelemetry/api';
import {
Detector,
Resource,
ResourceDetectionConfig,
} from '@opentelemetry/resources';
import { Detector, Resource } from '@opentelemetry/resources';
import {
CloudProviderValues,
CloudPlatformValues,
SemanticResourceAttributes,
} from '@opentelemetry/semantic-conventions';
import * as http from 'http';
import * as util from 'util';
import * as fs from 'fs';
import * as os from 'os';
import { getEnv } from '@opentelemetry/core';

const HTTP_TIMEOUT_IN_MS = 1000;

interface AwsLogOptions {
readonly 'awslogs-region'?: string;
readonly 'awslogs-group'?: string;
readonly 'awslogs-stream'?: string;
}

/**
* The AwsEcsDetector can be used to detect if a process is running in AWS
* ECS and return a {@link Resource} populated with data about the ECS
Expand All @@ -38,27 +43,37 @@ import { getEnv } from '@opentelemetry/core';
export class AwsEcsDetector implements Detector {
readonly CONTAINER_ID_LENGTH = 64;
readonly DEFAULT_CGROUP_PATH = '/proc/self/cgroup';

private static readFileAsync = util.promisify(fs.readFile);

async detect(_config?: ResourceDetectionConfig): Promise<Resource> {
async detect(): Promise<Resource> {
const env = getEnv();
if (!env.ECS_CONTAINER_METADATA_URI_V4 && !env.ECS_CONTAINER_METADATA_URI) {
diag.debug('AwsEcsDetector failed: Process is not on ECS');
return Resource.empty();
}

const hostName = os.hostname();
const containerId = await this._getContainerId();

return !hostName && !containerId
? Resource.empty()
: new Resource({
[SemanticResourceAttributes.CLOUD_PROVIDER]: CloudProviderValues.AWS,
[SemanticResourceAttributes.CLOUD_PLATFORM]:
CloudPlatformValues.AWS_ECS,
[SemanticResourceAttributes.CONTAINER_NAME]: hostName || '',
[SemanticResourceAttributes.CONTAINER_ID]: containerId || '',
});
const [containerAndHostnameResource, metadatav4Resource] =
blumamir marked this conversation as resolved.
Show resolved Hide resolved
await Promise.all([
this._getContainerIdAndHostnameResource(),
this._getMetadataV4Resource(),
]);

const metadataResource =
containerAndHostnameResource.merge(metadatav4Resource);

// if (!metadataResource.attributes) {
// return Resource.empty();
// }
mmanciop marked this conversation as resolved.
Show resolved Hide resolved

/*
* We return the Cloud Provider and Platform only when some other more detailed
* attributes are available
*/
return new Resource({
blumamir marked this conversation as resolved.
Show resolved Hide resolved
[SemanticResourceAttributes.CLOUD_PROVIDER]: CloudProviderValues.AWS,
[SemanticResourceAttributes.CLOUD_PLATFORM]: CloudPlatformValues.AWS_ECS,
}).merge(metadataResource);
}

/**
Expand All @@ -68,7 +83,10 @@ export class AwsEcsDetector implements Detector {
* we do not throw an error but throw warning message
* and then return null string
*/
private async _getContainerId(): Promise<string | undefined> {
private async _getContainerIdAndHostnameResource(): Promise<Resource> {
const hostName = os.hostname();

let containerId = '';
try {
const rawData = await AwsEcsDetector.readFileAsync(
blumamir marked this conversation as resolved.
Show resolved Hide resolved
this.DEFAULT_CGROUP_PATH,
Expand All @@ -77,13 +95,137 @@ export class AwsEcsDetector implements Detector {
const splitData = rawData.trim().split('\n');
for (const str of splitData) {
if (str.length > this.CONTAINER_ID_LENGTH) {
return str.substring(str.length - this.CONTAINER_ID_LENGTH);
containerId = str.substring(str.length - this.CONTAINER_ID_LENGTH);
break;
}
}
} catch (e) {
mmanciop marked this conversation as resolved.
Show resolved Hide resolved
diag.warn(`AwsEcsDetector failed to read container ID: ${e.message}`);
diag.warn('AwsEcsDetector failed to read container ID', e);
}
return undefined;

if (hostName || containerId) {
return new Resource({
[SemanticResourceAttributes.CONTAINER_NAME]: hostName || '',
blumamir marked this conversation as resolved.
Show resolved Hide resolved
[SemanticResourceAttributes.CONTAINER_ID]: containerId || '',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, I can see that the detector was and is using empty string when the value is unknown.
Wondering if it's cleaner to just not populate this attribute at all in this case 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is done consistently like this in the various ECS detectors across the SDKs that have them. Not sure if it is an especially good practice tbh.

});
}

return Resource.empty();
}

private async _getMetadataV4Resource(): Promise<Resource> {
const metadataUrl = getEnv().ECS_CONTAINER_METADATA_URI_V4;

if (!metadataUrl) {
return Resource.empty();
}

const [responseContainer, responseTask] = await Promise.all([
AwsEcsDetector._getUrlAsJson(metadataUrl),
AwsEcsDetector._getUrlAsJson(`${metadataUrl}/task`),
]);

const launchType: string = responseTask['LaunchType'];
const taskArn: string = responseTask['TaskARN'];

const baseArn: string = taskArn.substring(0, taskArn.lastIndexOf(':'));
const cluster: string = responseTask['Cluster'];

const clusterArn =
cluster.indexOf('arn:') == 0 ? cluster : `${baseArn}:cluster/${cluster}`;
blumamir marked this conversation as resolved.
Show resolved Hide resolved

const containerArn: string = responseContainer['ContainerARN'];

// https://github.com/open-telemetry/opentelemetry-specification/blob/main/semantic_conventions/resource/cloud_provider/aws/ecs.yaml
return new Resource({
[SemanticResourceAttributes.AWS_ECS_CONTAINER_ARN]: containerArn,
[SemanticResourceAttributes.AWS_ECS_CLUSTER_ARN]: clusterArn,
[SemanticResourceAttributes.AWS_ECS_LAUNCHTYPE]:
launchType?.toLowerCase(),
[SemanticResourceAttributes.AWS_ECS_TASK_ARN]: taskArn,
[SemanticResourceAttributes.AWS_ECS_TASK_FAMILY]: responseTask['Family'],
[SemanticResourceAttributes.AWS_ECS_TASK_REVISION]:
responseTask['Revision'],
blumamir marked this conversation as resolved.
Show resolved Hide resolved
}).merge(AwsEcsDetector._getLogResource(responseContainer));
}

private static _getLogResource(containerMetadata: any): Resource {
if (
containerMetadata['LogDriver'] != 'awslogs' ||
blumamir marked this conversation as resolved.
Show resolved Hide resolved
!containerMetadata['LogOptions']
) {
return Resource.EMPTY;
}

const containerArn = containerMetadata['ContainerARN']!;
const logOptions = containerMetadata['LogOptions'] as AwsLogOptions;

const logsRegion =
logOptions['awslogs-region'] ||
AwsEcsDetector._getRegionFromArn(containerArn);

const awsAccount = AwsEcsDetector._getAccountFromArn(containerArn);

const logsGroupName = logOptions['awslogs-group']!;
const logsGroupArn = `arn:aws:logs:${logsRegion}:${awsAccount}:log-group:${logsGroupName}`;
const logsStreamName = logOptions['awslogs-stream']!;
const logsStreamArn = `arn:aws:logs:${logsRegion}:${awsAccount}:log-group:${logsGroupName}:log-stream:${logsStreamName}`;

return new Resource({
[SemanticResourceAttributes.AWS_LOG_GROUP_NAMES]: [logsGroupName],
[SemanticResourceAttributes.AWS_LOG_GROUP_ARNS]: [logsGroupArn],
[SemanticResourceAttributes.AWS_LOG_STREAM_NAMES]: [logsStreamName],
[SemanticResourceAttributes.AWS_LOG_STREAM_ARNS]: [logsStreamArn],
blumamir marked this conversation as resolved.
Show resolved Hide resolved
});
}

private static _getAccountFromArn(containerArn: string): string {
const match = /arn:aws:ecs:[^:]+:([^:]+):.*/.exec(containerArn);
return match![1];
}

private static _getRegionFromArn(containerArn: string): string {
const match = /arn:aws:ecs:([^:]+):.*/.exec(containerArn);
return match![1];
}

private static _getUrlAsJson(url: string): Promise<any> {
return new Promise<string>((resolve, reject) => {
const request = http.get(url, (response: http.IncomingMessage) => {
if (response.statusCode && response.statusCode >= 400) {
reject(
new Error(
`Request to '${url}' failed with status ${response.statusCode}`
)
);
}
/*
* Concatenate the response out of chunks:
* https://nodejs.org/api/stream.html#stream_event_data
*/
let responseBody = '';
response.on(
'data',
(chunk: Buffer) => (responseBody += chunk.toString())
);
// All the data has been read, resolve the Promise
response.on('end', () => resolve(responseBody));
/*
* https://nodejs.org/api/http.html#httprequesturl-options-callback, see the
* 'In the case of a premature connection close after the response is received'
* case
*/
request.on('error', reject);
});

// Set an aggressive timeout to prevent lock-ups
request.setTimeout(HTTP_TIMEOUT_IN_MS, () => {
request.destroy();
});
// Connection error, disconnection, etc.
request.on('error', reject);
request.end();
}).then(responseBodyRaw => JSON.parse(responseBodyRaw));
}
}

Expand Down
Loading