lib/instrumentation/modules/@aws-sdk/client-s3.js (150 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';
const constants = require('../../../constants');
const NAME = 'S3';
const TYPE = 'storage';
const SUBTYPE = 's3';
const elasticAPMStash = Symbol('elasticAPMStash');
/**
* Gets the region from the ARN
*
* @param {String} s3Arn
* @returns {String}
*/
function regionFromS3Arn(s3Arn) {
return s3Arn.split(':')[3];
}
/**
* 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
*
* @param {String} bucket The bucket string
* @returns {String | null}
*/
function resourceFromBucket(bucket) {
let resource = null;
if (bucket) {
resource = bucket;
if (resource.startsWith('arn:')) {
resource = bucket.split(':').slice(5).join(':');
}
}
return resource;
}
/**
* Returns middlewares to instrument an S3Client instance
*
* @param {import('@aws-sdk/client-s3').S3Client} client
* @param {any} agent
* @returns {import('./smithy-client').AWSMiddlewareEntry[]}
*/
function s3MiddlewareFactory(client, agent) {
return [
{
middleware: (next, context) => async (args) => {
// Ensure there is a span from the wrapped `client.send()`.
const span = agent._instrumentation.currSpan();
if (!span || !(span.type === TYPE && span.subtype === SUBTYPE)) {
return await next(args);
}
const input = args.input;
const bucket = input && input.Bucket;
const resource = resourceFromBucket(bucket);
// The given span comes with the operation name and we need to
// add the resource if applies
if (resource) {
span.name += ' ' + resource;
span.setServiceTarget('s3', resource);
}
// 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 (input.Key) {
otelAttrs['aws.s3.key'] = input.Key;
}
}
let err;
let result;
let response;
let statusCode;
try {
result = await next(args);
response = result && result.response;
statusCode = response && response.statusCode;
} catch (ex) {
// Save the error for use in `finally` below, but re-throw it to
// not impact code flow.
err = ex;
// This code path happens with a GetObject conditional request
// that returns a 304 Not Modified.
statusCode = err && err.$metadata && err.$metadata.httpStatusCode;
throw ex;
} finally {
if (statusCode) {
span._setOutcomeFromHttpStatusCode(statusCode);
} else {
span._setOutcomeFromErrorCapture(constants.OUTCOME_FAILURE);
}
if (err && (!statusCode || statusCode >= 400)) {
agent.captureError(err, { skipOutcome: true });
}
// Set the httpContext
if (statusCode) {
const httpContext = {
status_code: statusCode,
};
if (
response &&
response.headers &&
response.headers['content-length']
) {
const encodedBodySize = Number(
response.headers['content-length'],
);
if (!isNaN(encodedBodySize)) {
httpContext.response = { encoded_body_size: encodedBodySize };
}
}
span.setHttpContext(httpContext);
}
// Configuring `new S3Client({useArnRegion:true})` allows one to
// use an Access Point bucket ARN for a region *other* than the
// one for which the client is configured. Therefore, we attempt
// to get the bucket region from the ARN first.
const config = client.config;
let useArnRegion;
if (typeof config.useArnRegion === 'boolean') {
useArnRegion = config.useArnRegion;
} else {
useArnRegion = await config.useArnRegion();
}
let region;
if (useArnRegion && bucket && bucket.startsWith('arn:')) {
region = regionFromS3Arn(args.input.Bucket);
} else {
region =
typeof config.region === 'boolean'
? region
: await config.region();
}
// Destination context.
const destContext = {
service: {
name: SUBTYPE,
type: TYPE,
},
};
if (context[elasticAPMStash]) {
destContext.address = context[elasticAPMStash].hostname;
destContext.port = context[elasticAPMStash].port;
}
if (resource) {
destContext.service.resource = resource;
}
if (region) {
destContext.cloud = { region };
}
span._setDestinationContext(destContext);
span.end();
}
return result;
},
options: { step: 'initialize', priority: 'high', name: 'elasticAPMSpan' },
},
{
middleware: (next, context) => async (args) => {
const req = args.request;
let port = req.port;
// Resolve port for HTTP(S) protocols
if (port === undefined) {
if (req.protocol === 'https:') {
port = 443;
} else if (req.protocol === 'http:') {
port = 80;
}
}
context[elasticAPMStash] = {
hostname: req.hostname,
port,
};
return next(args);
},
options: { step: 'finalizeRequest', name: 'elasticAPMHTTPInfo' },
},
];
}
/**
* Tells if the command needs to be ingored
* @param {import('@aws-sdk/types').Command} command the command sent by the SNS client
* @param {any} config the agent configuration
* @returns {boolean} false if the command should create a span
*/
function s3ShouldIgnoreCommand(command, config) {
return false;
}
module.exports = {
S3_NAME: NAME,
S3_TYPE: TYPE,
S3_SUBTYPE: SUBTYPE,
s3MiddlewareFactory,
s3ShouldIgnoreCommand,
};