|
| 1 | +'use strict' |
| 2 | + |
| 3 | +// Instrument AWS S3 operations via the 'aws-sdk' package. |
| 4 | + |
| 5 | +const constants = require('../../../constants') |
| 6 | + |
| 7 | +const TYPE = 'storage' |
| 8 | +const SUBTYPE = 's3' |
| 9 | + |
| 10 | +// Return the PascalCase operation name from `request.operation` by undoing to |
| 11 | +// `lowerFirst()` from |
| 12 | +// https://github.com/aws/aws-sdk-js/blob/c0c44b8a4e607aae521686898f39a3e359f727e4/lib/model/api.js#L63-L65 |
| 13 | +// |
| 14 | +// For example: 'headBucket' -> 'HeadBucket' |
| 15 | +function opNameFromOperation (operation) { |
| 16 | + return operation[0].toUpperCase() + operation.slice(1) |
| 17 | +} |
| 18 | + |
| 19 | +// Return an APM "resource" string for the bucket, Access Point ARN, or Outpost |
| 20 | +// ARN. ARNs are normalized to a shorter resource name. |
| 21 | +// |
| 22 | +// Known ARN patterns: |
| 23 | +// - arn:aws:s3:<region>:<account-id>:accesspoint/<accesspoint-name> |
| 24 | +// - arn:aws:s3-outposts:<region>:<account>:outpost/<outpost-id>/bucket/<bucket-name> |
| 25 | +// - arn:aws:s3-outposts:<region>:<account>:outpost/<outpost-id>/accesspoint/<accesspoint-name> |
| 26 | +// |
| 27 | +// In general that is: |
| 28 | +// arn:$partition:$service:$region:$accountId:$resource |
| 29 | +// |
| 30 | +// This parses using the same "split on colon" used by the JavaScript AWS SDK v3. |
| 31 | +// https://github.com/aws/aws-sdk-js-v3/blob/v3.18.0/packages/util-arn-parser/src/index.ts#L14-L37 |
| 32 | +function resourceFromBucket (bucket) { |
| 33 | + let resource = null |
| 34 | + if (bucket) { |
| 35 | + resource = bucket |
| 36 | + if (resource.startsWith('arn:')) { |
| 37 | + resource = bucket.split(':').slice(5).join(':') |
| 38 | + } |
| 39 | + } |
| 40 | + return resource |
| 41 | +} |
| 42 | + |
| 43 | +// Instrument an [email protected] operation (i.e. a AWS.Request.send or |
| 44 | +// AWS.Request.promise). |
| 45 | +// |
| 46 | +// @param {AWS.Request} request https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Request.html |
| 47 | +function instrumentationS3 (orig, origArguments, request, AWS, agent, { version, enabled }) { |
| 48 | + const opName = opNameFromOperation(request.operation) |
| 49 | + let name = 'S3 ' + opName |
| 50 | + const resource = resourceFromBucket(request.params && request.params.Bucket) |
| 51 | + if (resource) { |
| 52 | + name += ' ' + resource |
| 53 | + } |
| 54 | + |
| 55 | + const span = agent.startSpan(name, TYPE, SUBTYPE, opName) |
| 56 | + if (span) { |
| 57 | + request.on('complete', function onComplete (response) { |
| 58 | + // `response` is an AWS.Response |
| 59 | + // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Response.html |
| 60 | + |
| 61 | + // Determining the bucket's region. |
| 62 | + // `request.httpRequest.region` isn't documented, but the aws-sdk@2 |
| 63 | + // lib/services/s3.js will set it to the bucket's determined region. |
| 64 | + // This can be asynchronously determined -- e.g. if it differs from the |
| 65 | + // configured service endpoint region -- so this won't be set until |
| 66 | + // 'complete'. |
| 67 | + const region = request.httpRequest && request.httpRequest.region |
| 68 | + |
| 69 | + // Destination context. |
| 70 | + // '.httpRequest.endpoint' might differ from '.service.endpoint' if |
| 71 | + // the bucket is in a different region. |
| 72 | + const endpoint = request.httpRequest && request.httpRequest.endpoint |
| 73 | + const destContext = { |
| 74 | + service: { |
| 75 | + name: SUBTYPE, |
| 76 | + type: TYPE |
| 77 | + } |
| 78 | + } |
| 79 | + if (endpoint) { |
| 80 | + destContext.address = endpoint.hostname |
| 81 | + destContext.port = endpoint.port |
| 82 | + } |
| 83 | + if (resource) { |
| 84 | + destContext.service.resource = resource |
| 85 | + } |
| 86 | + if (region) { |
| 87 | + destContext.cloud = { region } |
| 88 | + } |
| 89 | + span.setDestinationContext(destContext) |
| 90 | + |
| 91 | + if (response) { |
| 92 | + // Follow the spec for HTTP client span outcome. |
| 93 | + // https://github.com/elastic/apm/blob/master/specs/agents/tracing-instrumentation-http.md#outcome |
| 94 | + // |
| 95 | + // For example, a S3 GetObject conditional request (e.g. using the |
| 96 | + // IfNoneMatch param) will respond with response.error=NotModifed and |
| 97 | + // statusCode=304. This is a *successful* outcome. |
| 98 | + const statusCode = response.httpResponse && response.httpResponse.statusCode |
| 99 | + if (statusCode) { |
| 100 | + span._setOutcomeFromHttpStatusCode(statusCode) |
| 101 | + } else { |
| 102 | + // `statusCode` will be undefined for errors before sending a request, e.g.: |
| 103 | + // InvalidConfiguration: Custom endpoint is not compatible with access point ARN |
| 104 | + span._setOutcomeFromErrorCapture(constants.OUTCOME_FAILURE) |
| 105 | + } |
| 106 | + |
| 107 | + if (response.error && (!statusCode || statusCode >= 400)) { |
| 108 | + agent.captureError(response.error, { skipOutcome: true }) |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + // Workaround a bug in the agent's handling of `span.sync`. |
| 113 | + // |
| 114 | + // The bug: Currently this span.sync is not set `false` because there is |
| 115 | + // an HTTP span created (for this S3 request) in the same async op. That |
| 116 | + // HTTP span becomes the "active span" for this async op, and *it* gets |
| 117 | + // marked as sync=false in `before()` in async-hooks.js. |
| 118 | + span.sync = false |
| 119 | + |
| 120 | + span.end() |
| 121 | + }) |
| 122 | + } |
| 123 | + |
| 124 | + return orig.apply(request, origArguments) |
| 125 | +} |
| 126 | + |
| 127 | +module.exports = { |
| 128 | + instrumentationS3 |
| 129 | +} |
0 commit comments