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, };