Skip to content

Commit 9ece910

Browse files
authored
feat: AWS S3 instrumentation of aws-sdk package (#2112)
Note that this only includes 'aws-sdk@2' instrumentation. Instrumentation of '@awk-sdk/client-s3' (aka "v3" or the "Modular AWS SDK for JavaScript") will be done in a separate change. This adds localstack to the test suite for testing the S3 API. Fixes: #1954
1 parent 98d2aa1 commit 9ece910

25 files changed

+811
-9
lines changed

.ci/Jenkinsfile

+1
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ def generateStepForWindows(Map params = [:]){
460460
"ELASTIC_APM_ASYNC_HOOKS=${ELASTIC_APM_ASYNC_HOOKS}",
461461
"CASSANDRA_HOST=${linuxIp}",
462462
"ES_HOST=${linuxIp}",
463+
"LOCALSTACK_HOST=${linuxIp}",
463464
"MEMCACHED_HOST=${linuxIp}",
464465
"MONGODB_HOST=${linuxIp}",
465466
"MSSQL_HOST=${linuxIp}",

.ci/docker/docker-compose-all.yml

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ services:
3333
extends:
3434
file: docker-compose.yml
3535
service: memcached
36+
localstack:
37+
extends:
38+
file: docker-compose.yml
39+
service: localstack
3640
node_tests:
3741
extends:
3842
file: docker-compose-node-test.yml
@@ -54,6 +58,8 @@ services:
5458
condition: service_healthy
5559
memcached:
5660
condition: service_healthy
61+
localstack:
62+
condition: service_healthy
5763

5864
volumes:
5965
nodepgdata:
@@ -68,3 +74,5 @@ volumes:
6874
driver: local
6975
nodecassandradata:
7076
driver: local
77+
nodelocalstackdata:
78+
driver: local

.ci/docker/docker-compose-edge.yml

+8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ services:
99
extends:
1010
file: docker-compose.yml
1111
service: elasticsearch
12+
localstack:
13+
extends:
14+
file: docker-compose.yml
15+
service: localstack
1216
memcached:
1317
extends:
1418
file: docker-compose.yml
@@ -42,6 +46,8 @@ services:
4246
condition: service_healthy
4347
elasticsearch:
4448
condition: service_healthy
49+
localstack:
50+
condition: service_healthy
4551
memcached:
4652
condition: service_healthy
4753
mongodb:
@@ -64,6 +70,8 @@ volumes:
6470
driver: local
6571
nodemysqldata:
6672
driver: local
73+
nodelocalstackdata:
74+
driver: local
6775
nodeesdata:
6876
driver: local
6977
nodecassandradata:
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: '2.1'
2+
3+
services:
4+
localstack:
5+
extends:
6+
file: docker-compose.yml
7+
service: localstack
8+
node_tests:
9+
extends:
10+
file: docker-compose-node-test.yml
11+
service: node_tests
12+
depends_on:
13+
localstack:
14+
condition: service_healthy

.ci/docker/docker-compose-node-edge-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ services:
2222
PGHOST: 'postgres'
2323
PGUSER: 'postgres'
2424
MEMCACHED_HOST: 'memcached'
25+
LOCALSTACK_HOST: 'localstack'
2526
NODE_VERSION: ${NODE_VERSION}
2627
NVM_NODEJS_ORG_MIRROR: ${NVM_NODEJS_ORG_MIRROR}
2728
ELASTIC_APM_ASYNC_HOOKS: ${ELASTIC_APM_ASYNC_HOOKS}

.ci/docker/docker-compose-node-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ services:
2020
PGHOST: 'postgres'
2121
PGUSER: 'postgres'
2222
MEMCACHED_HOST: 'memcached'
23+
LOCALSTACK_HOST: 'localstack'
2324
NODE_VERSION: ${NODE_VERSION}
2425
TAV: ${TAV_MODULE}
2526
ELASTIC_APM_ASYNC_HOOKS: ${ELASTIC_APM_ASYNC_HOOKS}

.ci/docker/docker-compose.yml

+18
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ services:
114114
timeout: 10s
115115
retries: 5
116116

117+
localstack:
118+
# https://hub.docker.com/r/localstack/localstack/tags
119+
image: localstack/localstack:0.12.12
120+
environment:
121+
- LOCALSTACK_SERVICES=s3
122+
- DATA_DIR=/var/lib/localstack
123+
ports:
124+
- "4566:4566"
125+
healthcheck:
126+
test: ["CMD", "curl", "-f", "http://localhost:4566/health"]
127+
interval: 30s
128+
timeout: 10s
129+
retries: 5
130+
volumes:
131+
- nodelocalstackdata:/var/lib/localstack
132+
117133
volumes:
118134
nodepgdata:
119135
driver: local
@@ -127,3 +143,5 @@ volumes:
127143
driver: local
128144
nodecassandradata:
129145
driver: local
146+
nodelocalstackdata:
147+
driver: local

.ci/scripts/docker-test.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ fi
1515
# Skip for node v8 because it results in this warning:
1616
# openssl config failed: error:25066067:DSO support routines:DLFCN_LOAD:could not load the shared library
1717
if [[ $major_node_version -gt 8 ]]; then
18-
export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS}} --openssl-config=./test/openssl-config-for-testing.cnf"
18+
export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS}} --openssl-config=$(pwd)/test/openssl-config-for-testing.cnf"
1919
fi
2020

2121
# Workaround to git <2.7

.ci/scripts/test.sh

+5
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ elif [[ -n "${TAV_MODULE}" ]]; then
200200
memcached)
201201
DOCKER_COMPOSE_FILE=docker-compose-memcached.yml
202202
;;
203+
aws-sdk)
204+
DOCKER_COMPOSE_FILE=docker-compose-localstack.yml
205+
;;
203206
*)
204207
# Just the "node_tests" container. No additional services needed for testing.
205208
DOCKER_COMPOSE_FILE=docker-compose-node-test.yml
@@ -210,6 +213,8 @@ else
210213
DOCKER_COMPOSE_FILE=docker-compose-all.yml
211214
fi
212215

216+
ELASTIC_APM_ASYNC_HOOKS=${ELASTIC_APM_ASYNC_HOOKS:-true}
217+
213218
set +e
214219
NVM_NODEJS_ORG_MIRROR=${NVM_NODEJS_ORG_MIRROR} \
215220
ELASTIC_APM_ASYNC_HOOKS=${ELASTIC_APM_ASYNC_HOOKS} \

.github/workflows/test.yml

+10
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ jobs:
105105
volumes:
106106
- nodeesdata:/usr/share/elasticsearch/data
107107

108+
localstack:
109+
image: localstack/localstack:0.12.12
110+
env:
111+
LOCALSTACK_SERVICES: 's3'
112+
DATA_DIR: '/var/lib/localstack'
113+
ports:
114+
- "4566:4566"
115+
volumes:
116+
- nodelocalstackdata:/var/lib/localstack
117+
108118
strategy:
109119
matrix:
110120
node:

CHANGELOG.asciidoc

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ Notes:
3636
[float]
3737
===== Features
3838
39+
* Add instrumentation of all AWS S3 methods when using the
40+
https://www.npmjs.com/package/aws-sdk[JavaScript AWS SDK v2] (`aws-sdk`).
41+
3942
[float]
4043
===== Bug fixes
4144

docs/supported-technologies.asciidoc

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ The Node.js agent will automatically instrument the following modules to give yo
7272
[options="header"]
7373
|=======================================================================
7474
|Module |Version |Note
75-
|https://www.npmjs.com/package/aws-sdk[aws-sdk] |>1 <3 |Will instrument SQS send/receive/delete messages
75+
|https://www.npmjs.com/package/aws-sdk[aws-sdk] |>1 <3 |Will instrument SQS send/receive/delete messages, all S3 methods
7676
|https://www.npmjs.com/package/cassandra-driver[cassandra-driver] |>=3.0.0 |Will instrument all queries
7777
|https://www.npmjs.com/package/elasticsearch[elasticsearch] |>=8.0.0 |Will instrument all queries
7878
|https://www.npmjs.com/package/@elastic/elasticsearch[@elastic/elasticsearch] |>=7.0.0 <8.0.0 |Will instrument all queries

lib/agent.js

+1
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ const EMPTY_OPTS = {}
321321
//
322322
// Usage:
323323
// captureError(err, opts, cb)
324+
// captureError(err, opts)
324325
// captureError(err, cb)
325326
//
326327
// where:

lib/instrumentation/modules/aws-sdk.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
'use strict'
22
const semver = require('semver')
33
const shimmer = require('../shimmer')
4+
const { instrumentationS3 } = require('./aws-sdk/s3')
45
const { instrumentationSqs } = require('./aws-sdk/sqs')
56

7+
const instrumentorFromSvcId = {
8+
s3: instrumentationS3,
9+
sqs: instrumentationSqs
10+
}
11+
612
// Called in place of AWS.Request.send and AWS.Request.promise
713
//
814
// Determines which amazon service an API request is for
915
// and then passes call on to an appropriate instrumentation
1016
// function.
1117
function instrumentOperation (orig, origArguments, request, AWS, agent, { version, enabled }) {
12-
if (request.service.serviceIdentifier === 'sqs') {
13-
return instrumentationSqs(orig, origArguments, request, AWS, agent, { version, enabled })
18+
const instrumentor = instrumentorFromSvcId[request.service.serviceIdentifier]
19+
if (instrumentor) {
20+
return instrumentor(orig, origArguments, request, AWS, agent, { version, enabled })
1421
}
1522

1623
// if we're still here, then we still need to call the original method
+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
}

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
"thunky": "^1.1.0",
184184
"typescript": "^3.7.5",
185185
"untildify": "^4.0.0",
186+
"vasync": "^2.2.0",
186187
"wait-on": "^3.3.0",
187188
"ws": "^7.2.1"
188189
},
File renamed without changes.

test/docker-compose.ci.yml

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ version: '2.1'
33
services:
44
node_tests:
55
image: node:${NODE_VERSION}
6-
environment:
6+
environment:
77
MONGODB_HOST: 'mongodb'
88
REDIS_HOST: 'redis'
99
ES_HOST: 'elasticsearch'
1010
MSSQL_HOST: 'mssql'
1111
MYSQL_HOST: 'mysql'
1212
CASSANDRA_HOST: 'cassandra'
1313
MEMCACHED_HOST: 'memcached'
14+
LOCALSTACK_HOST: 'localstack'
1415
PGHOST: 'postgres'
1516
PGUSER: 'postgres'
1617
depends_on:
@@ -30,3 +31,5 @@ services:
3031
condition: service_started
3132
memcached:
3233
condition: service_started
34+
localstack:
35+
condition: service_started

0 commit comments

Comments
 (0)