lib/instrumentation/modules/aws-sdk/s3.js (103 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
'use strict';
// Instrument AWS S3 operations via the 'aws-sdk' package.
const constants = require('../../../constants');
const TYPE = 'storage';
const SUBTYPE = 's3';
// Return the PascalCase operation name from `request.operation` by undoing to
// `lowerFirst()` from
// https://github.com/aws/aws-sdk-js/blob/c0c44b8a4e607aae521686898f39a3e359f727e4/lib/model/api.js#L63-L65
//
// For example: 'headBucket' -> 'HeadBucket'
function opNameFromOperation(operation) {
return operation[0].toUpperCase() + operation.slice(1);
}
// Return an APM "resource" string for the bucket, Access Point ARN, or Outpost
// ARN. ARNs are normalized to a shorter resource name.
//
// Known ARN patterns:
// - arn:aws:s3:<region>:<account-id>:accesspoint/<accesspoint-name>
// - arn:aws:s3-outposts:<region>:<account>:outpost/<outpost-id>/bucket/<bucket-name>
// - arn:aws:s3-outposts:<region>:<account>:outpost/<outpost-id>/accesspoint/<accesspoint-name>
//
// In general that is:
// arn:$partition:$service:$region:$accountId:$resource
//
// This parses using the same "split on colon" used by the JavaScript AWS SDK v3.
// https://github.com/aws/aws-sdk-js-v3/blob/v3.18.0/packages/util-arn-parser/src/index.ts#L14-L37
function resourceFromBucket(bucket) {
let resource = null;
if (bucket) {
resource = bucket;
if (resource.startsWith('arn:')) {
resource = bucket.split(':').slice(5).join(':');
}
}
return resource;
}
// Instrument an awk-sdk@2.x operation (i.e. a AWS.Request.send or
// AWS.Request.promise).
//
// @param {AWS.Request} request https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Request.html
function instrumentationS3(
orig,
origArguments,
request,
AWS,
agent,
{ version, enabled },
) {
const opName = opNameFromOperation(request.operation);
const params = request.params;
const bucket = params && params.Bucket;
const resource = resourceFromBucket(bucket);
let name = 'S3 ' + opName;
if (resource) {
name += ' ' + resource;
}
const ins = agent._instrumentation;
const span = ins.createSpan(name, TYPE, SUBTYPE, opName, { exitSpan: true });
if (!span) {
return orig.apply(request, origArguments);
}
// As for now OTel spec defines attributes for operations that require a Bucket
// if that changes we should review this guard
// https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/semantic_conventions/trace/instrumentation/aws-sdk.yml#L435
if (bucket) {
const otelAttrs = span._getOTelAttributes();
otelAttrs['aws.s3.bucket'] = bucket;
if (params.Key) {
otelAttrs['aws.s3.key'] = params.Key;
}
}
const onComplete = function (response) {
// `response` is an AWS.Response
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Response.html
// Determining the bucket's region.
// `request.httpRequest.region` isn't documented, but the aws-sdk@2
// lib/services/s3.js will set it to the bucket's determined region.
// This can be asynchronously determined -- e.g. if it differs from the
// configured service endpoint region -- so this won't be set until
// 'complete'.
const httpRequest = request.httpRequest;
const region = httpRequest && httpRequest.region;
span.setServiceTarget('s3', resource);
const destContext = {};
// '.httpRequest.endpoint' might differ from '.service.endpoint' if
// the bucket is in a different region.
const endpoint = httpRequest && httpRequest.endpoint;
if (endpoint) {
destContext.address = endpoint.hostname;
destContext.port = endpoint.port;
}
if (region) {
destContext.cloud = { region };
}
span._setDestinationContext(destContext);
if (response) {
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/HttpResponse.html
const httpResponse = response.httpResponse;
let statusCode;
if (httpResponse) {
statusCode = httpResponse.statusCode;
// Set HTTP context. Some context not being set, though it is available:
// - method: Not that helpful.
// - url: Mostly redundant with context.destination.address.
// - response.headers: A lot of added size for uncertain utility. The
// inclusion of Amazon's request ID headers might be worth it.
const httpContext = {
status_code: statusCode,
};
const encodedBodySize =
Buffer.isBuffer(httpResponse.body) && httpResponse.body.byteLength;
if (encodedBodySize) {
// I'm not actually sure if this might be decoded_body_size.
httpContext.response = { encoded_body_size: encodedBodySize };
}
span.setHttpContext(httpContext);
}
// Follow the spec for HTTP client span outcome.
// https://github.com/elastic/apm/blob/main/specs/agents/tracing-instrumentation-http.md#outcome
//
// For example, a S3 GetObject conditional request (e.g. using the
// IfNoneMatch param) will respond with response.error=NotModifed and
// statusCode=304. This is a *successful* outcome.
if (statusCode) {
span._setOutcomeFromHttpStatusCode(statusCode);
} else {
// `statusCode` will be undefined for errors before sending a request, e.g.:
// InvalidConfiguration: Custom endpoint is not compatible with access point ARN
span._setOutcomeFromErrorCapture(constants.OUTCOME_FAILURE);
}
if (response.error && (!statusCode || statusCode >= 400)) {
agent.captureError(response.error, { skipOutcome: true });
}
}
span.end();
};
// Run context notes: The `orig` should run in the context of the S3 span,
// because that is the point. The user's callback `cb` should run outside of
// the S3 span.
const parentRunContext = ins.currRunContext();
const spanRunContext = parentRunContext.enterSpan(span);
const cb = origArguments[origArguments.length - 1];
if (typeof cb === 'function') {
origArguments[origArguments.length - 1] = ins.bindFunctionToRunContext(
parentRunContext,
cb,
);
}
request.on(
'complete',
ins.bindFunctionToRunContext(spanRunContext, onComplete),
);
return ins.withRunContext(spanRunContext, orig, request, ...origArguments);
}
module.exports = {
instrumentationS3,
};