diff --git a/.tav.yml b/.tav.yml index 3007906c6b..ebbdb344b3 100644 --- a/.tav.yml +++ b/.tav.yml @@ -419,8 +419,10 @@ aws-sdk: # is no need to test *all* those releases. Instead we statically list every # N=5 releases to test. # - # Maintenance note: This should be updated periodically. - versions: '2.858.0 || 2.863.0 || 2.868.0 || 2.873.0 || 2.878.0 || 2.883.0 || 2.888.0 || 2.893.0 || 2.898.0 || 2.903.0 || 2.908.0 || 2.913.0 || 2.918.0 || >2.918 <3' + # Maintenance note: This should be updated periodically, keeping 2.858 + # as the earliest version but updating the others. + versions: '2.858.0 || 2.881.0 || 2.886.0 || 2.891.0 || 2.896.0 || 2.901.0 || 2.906.0 || 2.911.0 || 2.916.0 || 2.921.0 || 2.926.0 || 2.931.0 || 2.936.0 || >2.936 <3' commands: - node test/instrumentation/modules/aws-sdk/s3.test.js - node test/instrumentation/modules/aws-sdk/sqs.js + - node test/instrumentation/modules/aws-sdk/dynamodb.js diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 942fe81d71..e37cf7cc12 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -41,6 +41,9 @@ Notes: * Add <> configuration option. This supports some use cases using the APM agent **without** an APM server. ({issues}2101[#2101]) +* Add instrumentation of all DynamoDB methods when using the + https://www.npmjs.com/package/aws-sdk[JavaScript AWS SDK v2] (`aws-sdk`). + [float] ===== Bug fixes diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index 4f2d649241..dc8616074e 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -72,7 +72,7 @@ The Node.js agent will automatically instrument the following modules to give yo [options="header"] |======================================================================= |Module |Version |Note -|https://www.npmjs.com/package/aws-sdk[aws-sdk] |>1 <3 |Will instrument SQS send/receive/delete messages, all S3 methods +|https://www.npmjs.com/package/aws-sdk[aws-sdk] |>1 <3 |Will instrument SQS send/receive/delete messages, all S3 methods, and all DynamoDB methods |https://www.npmjs.com/package/cassandra-driver[cassandra-driver] |>=3.0.0 |Will instrument all queries |https://www.npmjs.com/package/elasticsearch[elasticsearch] |>=8.0.0 |Will instrument all queries |https://www.npmjs.com/package/@elastic/elasticsearch[@elastic/elasticsearch] |>=7.0.0 <8.0.0 |Will instrument all queries diff --git a/lib/instrumentation/modules/aws-sdk.js b/lib/instrumentation/modules/aws-sdk.js index f5af25b22d..ae7dd7f344 100644 --- a/lib/instrumentation/modules/aws-sdk.js +++ b/lib/instrumentation/modules/aws-sdk.js @@ -3,10 +3,12 @@ const semver = require('semver') const shimmer = require('../shimmer') const { instrumentationS3 } = require('./aws-sdk/s3') const { instrumentationSqs } = require('./aws-sdk/sqs') +const { instrumentationDynamoDb } = require('./aws-sdk/dynamodb.js') const instrumentorFromSvcId = { s3: instrumentationS3, - sqs: instrumentationSqs + sqs: instrumentationSqs, + dynamodb: instrumentationDynamoDb } // Called in place of AWS.Request.send and AWS.Request.promise diff --git a/lib/instrumentation/modules/aws-sdk/dynamodb.js b/lib/instrumentation/modules/aws-sdk/dynamodb.js new file mode 100644 index 0000000000..e4b7180cc3 --- /dev/null +++ b/lib/instrumentation/modules/aws-sdk/dynamodb.js @@ -0,0 +1,125 @@ +'use strict' +const constants = require('../../../constants') +const TYPE = 'db' +const SUBTYPE = 'dynamodb' +const ACTION = 'query' + +function getRegionFromRequest (request) { + return request && request.service && + request.service.config && request.service.config.region +} + +function getPortFromRequest (request) { + return request && request.service && + request.service.endpoint && request.service.endpoint.port +} + +function getMethodFromRequest (request) { + const method = request && request.operation + if (method) { + return method[0].toUpperCase() + method.slice(1) + } +} + +function getStatementFromRequest (request) { + const method = getMethodFromRequest(request) + if (method === 'Query' && request && request.params && request.params.KeyConditionExpression) { + return request.params.KeyConditionExpression + } + return undefined +} + +function getAddressFromRequest (request) { + return request && request.service && request.service.endpoint && + request.service.endpoint.hostname +} + +function getTableFromRequest (request) { + const table = request && request.params && request.params.TableName + if (!table) { + return '' + } + return ` ${table}` +} + +// Creates the span name from request information +function getSpanNameFromRequest (request) { + const method = getMethodFromRequest(request) + const table = getTableFromRequest(request) + const name = `DynamoDB ${method}${table}` + return name +} + +function shouldIgnoreRequest (request, agent) { + return false +} + +// Main entrypoint for SQS instrumentation +// +// Must call (or one of its function calls must call) the +// `orig` function/method +function instrumentationDynamoDb (orig, origArguments, request, AWS, agent, { version, enabled }) { + if (shouldIgnoreRequest(request, agent)) { + return orig.apply(request, origArguments) + } + + const type = TYPE + const subtype = SUBTYPE + const action = ACTION + + const name = getSpanNameFromRequest(request) + const span = agent.startSpan(name, type, subtype, action) + if (!span) { + return orig.apply(request, origArguments) + } + + span.setDbContext({ + instance: getRegionFromRequest(request), + statement: getStatementFromRequest(request), + type: SUBTYPE + }) + span.setDestinationContext({ + address: getAddressFromRequest(request), + port: getPortFromRequest(request), + service: { + name: SUBTYPE, + type: 'db', + resource: SUBTYPE + }, + cloud: { + region: getRegionFromRequest(request) + } + }) + + request.on('complete', function (response) { + if (response && response.error) { + const errOpts = { + skipOutcome: true + } + agent.captureError(response.error, errOpts) + span._setOutcomeFromErrorCapture(constants.OUTCOME_FAILURE) + } + + // Workaround a bug in the agent's handling of `span.sync`. + // + // The bug: Currently this span.sync is not set `false` because there is + // an HTTP span created (for this S3 request) in the same async op. That + // HTTP span becomes the "active span" for this async op, and *it* gets + // marked as sync=false in `before()` in async-hooks.js. + span.sync = false + span.end() + }) + + return orig.apply(request, origArguments) +} + +module.exports = { + instrumentationDynamoDb, + + // exported for testing + getRegionFromRequest, + getPortFromRequest, + getStatementFromRequest, + getAddressFromRequest, + getMethodFromRequest +} diff --git a/test/instrumentation/modules/aws-sdk/dynamodb.js b/test/instrumentation/modules/aws-sdk/dynamodb.js new file mode 100644 index 0000000000..44ed96862e --- /dev/null +++ b/test/instrumentation/modules/aws-sdk/dynamodb.js @@ -0,0 +1,268 @@ +const agent = require('../../../..').start({ + serviceName: 'test', + secretToken: 'test', + captureExceptions: false, + metricsInterval: 0, + centralConfig: 'none', + logLevel: 'off' +}) +const tape = require('tape') +const AWS = require('aws-sdk') +const express = require('express') +const bodyParser = require('body-parser') +const fixtures = require('./fixtures/dynamodb') + +const mockClient = require('../../../_mock_http_client') + +const { + getRegionFromRequest, + getPortFromRequest, + getStatementFromRequest, + getAddressFromRequest, + getMethodFromRequest +} = + require('../../../../lib/instrumentation/modules/aws-sdk/dynamodb') + +initializeAwsSdk() + +function initializeAwsSdk () { + // SDk requires a region to be set + AWS.config.update({ region: 'us-west-2' }) + + // without fake credentials the aws-sdk will attempt to fetch + // credentials as though it was on an EC2 instance + process.env.AWS_ACCESS_KEY_ID = 'fake-1' + process.env.AWS_SECRET_ACCESS_KEY = 'fake-2' +} + +function createMockServer (fixture) { + const app = express() + app.use(bodyParser.urlencoded({ extended: false })) + app.post('/', (req, res) => { + res.status(fixture.httpStatusCode) + res.setHeader('Content-Type', 'application/javascript') + res.send(fixture.response) + }) + return app +} + +function resetAgent (cb) { + agent._instrumentation.currentTransaction = null + agent._transport = mockClient(cb) +} + +tape.test('AWS DynamoDB: Unit Test Functions', function (test) { + test.test('function getRegionFromRequest', function (t) { + const request = { + service: { + config: { + region: 'us-west-2' + } + } + } + t.equals(getRegionFromRequest(request), 'us-west-2') + t.equals(getRegionFromRequest({}), undefined) + t.equals(getRegionFromRequest({ service: null }), null) + t.equals(getRegionFromRequest({ service: { config: null } }), null) + t.equals(getRegionFromRequest({ service: { config: { region: null } } }), null) + t.equals(getRegionFromRequest(), undefined) + t.equals(getRegionFromRequest(null), null) + t.end() + }) + + test.test('function getPortFromRequest', function (t) { + const request = { + service: { + endpoint: { + port: 443 + } + } + } + t.equals(getPortFromRequest(request), 443) + t.equals(getPortFromRequest({}), undefined) + t.equals(getPortFromRequest({ service: null }), null) + t.equals(getPortFromRequest({ service: { endpoint: null } }), null) + t.equals(getPortFromRequest({ service: { endpoint: { port: null } } }), null) + t.equals(getPortFromRequest(), undefined) + t.equals(getPortFromRequest(null), null) + t.end() + }) + + test.test('function getStatementFromRequest', function (t) { + const request = { + operation: 'query', + params: { + KeyConditionExpression: 'foo = :bar' + } + } + t.equals(getStatementFromRequest(request), 'foo = :bar') + t.equals(getStatementFromRequest({}), undefined) + t.equals(getStatementFromRequest({ operation: null }), undefined) + t.equals(getStatementFromRequest({ operation: 'query', params: {} }), undefined) + t.equals(getStatementFromRequest({ operation: 'query', params: { KeyConditionExpression: null } }), undefined) + t.equals(getStatementFromRequest(), undefined) + t.equals(getStatementFromRequest(null), undefined) + t.end() + }) + + test.test('function getAddressFromRequest', function (t) { + const request = { + service: { + endpoint: { + hostname: 'dynamodb.us-west-2.amazonaws.com' + } + } + } + t.equals(getAddressFromRequest(request), 'dynamodb.us-west-2.amazonaws.com') + t.equals(getAddressFromRequest({}), undefined) + t.equals(getAddressFromRequest({ service: null }), null) + t.equals(getAddressFromRequest({ service: { endpoint: null } }), null) + t.equals(getAddressFromRequest({ service: { endpoint: { hostname: null } } }), null) + t.equals(getAddressFromRequest(), undefined) + t.equals(getAddressFromRequest(null), null) + t.end() + }) + + test.test('function getMethodFromRequest', function (t) { + const request = { + operation: 'query' + } + t.equals(getMethodFromRequest(request), 'Query') + t.equals(getMethodFromRequest({}), undefined) + t.equals(getMethodFromRequest({ operation: null }), undefined) + t.equals(getAddressFromRequest(), undefined) + t.equals(getAddressFromRequest(null), null) + + t.end() + }) +}) + +tape.test('AWS DynamoDB: End to End Test', function (test) { + test.test('API: query', function (t) { + const app = createMockServer( + fixtures.query + ) + const listener = app.listen(0, function () { + resetAgent(function (data) { + const span = data.spans.filter((span) => span.type === 'db').pop() + t.equals(span.name, 'DynamoDB Query fixture-table', 'span named correctly') + t.equals(span.type, 'db', 'span type correctly set') + t.equals(span.subtype, 'dynamodb', 'span subtype set correctly') + t.equals(span.action, 'query', 'query set correctly') + t.equals(span.context.db.statement, 'id = :foo', 'statment set in context correctly') + t.equals(span.context.destination.service.name, 'dynamodb', 'service name in destination context') + t.end() + }) + const port = listener.address().port + AWS.config.update({ + endpoint: `http://localhost:${port}` + }) + agent.startTransaction('myTransaction') + var ddb = new AWS.DynamoDB({ apiVersion: '2012-08-10' }) + var params = { + TableName: 'fixture-table', + KeyConditionExpression: 'id = :foo', + ExpressionAttributeValues: { + ':foo': { S: '001' } + } + } + ddb.query(params, function (err, data) { + t.error(err) + agent.endTransaction() + listener.close() + }) + }) + }) + + test.test('API: listTable', function (t) { + const app = createMockServer( + fixtures.listTable + ) + const listener = app.listen(0, function () { + resetAgent(function (data) { + const span = data.spans.filter((span) => span.type === 'db').pop() + t.equals(span.name, 'DynamoDB ListTables', 'span named correctly') + t.equals(span.type, 'db', 'span type correctly set') + t.equals(span.subtype, 'dynamodb', 'span subtype set correctly') + t.equals(span.action, 'query', 'query set correctly') + t.equals(span.context.destination.service.name, 'dynamodb', 'service name in destination context') + + t.end() + }) + const port = listener.address().port + AWS.config.update({ + endpoint: `http://localhost:${port}` + }) + agent.startTransaction('myTransaction') + var ddb = new AWS.DynamoDB({ apiVersion: '2012-08-10' }) + ddb.listTables(function (err, data) { + t.error(err) + agent.endTransaction() + listener.close() + }) + }) + }) + + test.test('API: error', function (t) { + const app = createMockServer( + fixtures.error + ) + const listener = app.listen(0, function () { + resetAgent(function (data) { + t.equals(data.errors.length, 1, 'expect captured error') + const span = data.spans.filter((span) => span.type === 'db').pop() + t.ok(span, 'expect a db span') + t.equals(span.outcome, 'failure', 'expect db span to have failure outcome') + t.end() + }) + const port = listener.address().port + AWS.config.update({ + endpoint: `http://localhost:${port}` + }) + agent.startTransaction('myTransaction') + var ddb = new AWS.DynamoDB({ apiVersion: '2012-08-10' }) + var params = { + TableName: 'fixture-table', + KeyConditionExpression: 'id = :foo', + ExpressionAttributeValues: { + ':foo': { S: '001' } + } + } + ddb.query(params, function (err, data) { + t.ok(err, 'expect error') + agent.endTransaction() + listener.close() + }) + }) + }) + + tape.test('AWS DynamoDB: No Transaction', function (test) { + test.test('API: query', function (t) { + const app = createMockServer( + fixtures.query + ) + const listener = app.listen(0, function () { + resetAgent(function (data) { + t.equals(data.spans.length, 0, 'no spans without transaction') + t.end() + }) + const port = listener.address().port + AWS.config.update({ + endpoint: `http://localhost:${port}` + }) + var ddb = new AWS.DynamoDB({ apiVersion: '2012-08-10' }) + var params = { + TableName: 'fixture-table', + KeyConditionExpression: 'id = :foo', + ExpressionAttributeValues: { + ':foo': { S: '001' } + } + } + ddb.query(params, function (err, data) { + t.error(err) + listener.close() + }) + }) + }) + }) +}) diff --git a/test/instrumentation/modules/aws-sdk/fixtures/dynamodb.js b/test/instrumentation/modules/aws-sdk/fixtures/dynamodb.js new file mode 100644 index 0000000000..0d47fc283e --- /dev/null +++ b/test/instrumentation/modules/aws-sdk/fixtures/dynamodb.js @@ -0,0 +1,19 @@ +'use strict' +module.exports = { + query: { + response: { + Count: 1, + Items: [{ id: { S: '001' }, name: { S: 'Richard Roe' }, number: { N: '1' } }], + ScannedCount: 1 + }, + httpStatusCode: 200 + }, + listTable: { + response: { Success: { TableNames: ['fixture-table'] } }, + httpStatusCode: 200 + }, + error: { + response: { __type: 'com.amazonaws.dynamodb.v20120810#ResourceNotFoundException', message: 'Requested resource not found' }, + httpStatusCode: 400 + } +} diff --git a/test/instrumentation/modules/aws-sdk/fixtures-sqs.js b/test/instrumentation/modules/aws-sdk/fixtures/sqs.js similarity index 100% rename from test/instrumentation/modules/aws-sdk/fixtures-sqs.js rename to test/instrumentation/modules/aws-sdk/fixtures/sqs.js diff --git a/test/instrumentation/modules/aws-sdk/sqs.js b/test/instrumentation/modules/aws-sdk/sqs.js index e9b7575d98..01c560b166 100644 --- a/test/instrumentation/modules/aws-sdk/sqs.js +++ b/test/instrumentation/modules/aws-sdk/sqs.js @@ -12,7 +12,7 @@ const tape = require('tape') const AWS = require('aws-sdk') const express = require('express') const bodyParser = require('body-parser') -const fixtures = require('./fixtures-sqs') +const fixtures = require('./fixtures/sqs') const logging = require('../../../../lib/logging') const mockClient = require('../../../_mock_http_client')