UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

383 lines (319 loc) 11.9 kB
'use strict' const analyticsSampler = require('../../dd-trace/src/analytics_sampler') const ClientPlugin = require('../../dd-trace/src/plugins/client') const { storage } = require('../../datadog-core') const { tagsFromRequest, tagsFromResponse } = require('../../dd-trace/src/payload-tagging') const getConfig = require('../../dd-trace/src/config') const { IS_SERVERLESS } = require('../../dd-trace/src/serverless') const RESPONSE_SKIP_KEYS = new Set(['request', 'requestId', 'error', '$metadata']) class BaseAwsSdkPlugin extends ClientPlugin { static id = 'aws' static isPayloadReporter = false /** * Append `"<key>": <JSON.stringify(value)>` to a JSON-encoded object * payload without re-parsing when possible. * * Fast path: `payload` is `{}` (returns `{"<key>":<json>}`) or ends with * `}` preceded by a non-whitespace, non-`{` byte and does not contain * `"<key>"` anywhere. The new field is spliced in before the trailing * brace. * * Slow path falls back to `JSON.parse` + assign + `JSON.stringify` so the * result still matches the previous round-trip when the payload has * whitespace before the trailing `}`, is not a JSON object, or already * contains `key`. The slow path replaces an existing `key` rather than * merging — callers that need to preserve nested fields under `key` must * read and merge before calling. * * @param {string} payload * @param {string} key Top-level key to insert. Must be a simple * identifier that does not need JSON escaping. * @param {object} value Value to inject; will be `JSON.stringify`'d. * @returns {string} */ static injectFieldIntoJsonObject (payload, key, value) { const last = payload.length - 1 if (last >= 1 && payload[last] === '}') { if (last === 1) { return `{"${key}":${JSON.stringify(value)}}` } const before = payload.charCodeAt(last - 1) const isWhitespace = before === 0x20 || before === 0x09 || before === 0x0A || before === 0x0D if (!isWhitespace && before !== 0x7B && !payload.includes(`"${key}"`)) { return `${payload.slice(0, last)},"${key}":${JSON.stringify(value)}}` } } const obj = JSON.parse(payload) obj[key] = value return JSON.stringify(obj) } get serviceIdentifier () { const id = this.constructor.id.toLowerCase() Object.defineProperty(this, 'serviceIdentifier', { configurable: true, writable: true, enumerable: true, value: id, }) return id } /** @type {import('../../dd-trace/src/config/config-types').ConfigProperties['cloudPayloadTagging']} */ get cloudTaggingConfig () { return this._tracerConfig.cloudPayloadTagging } get payloadTaggingRules () { return this.cloudTaggingConfig.rules?.aws?.[this.constructor.id] } constructor (...args) { super(...args) this._parentMap = new WeakMap() this.addBind(`apm:aws:request:start:${this.serviceIdentifier}`, (ctx) => { const { request, operation, awsRegion, awsService, } = ctx const parentStore = ctx.parentStore = storage('legacy').getStore() const childOf = parentStore?.span this._parentMap.set(request, parentStore) if (!this.isEnabled(request)) { return parentStore } const meta = { 'span.kind': 'client', 'aws.operation': operation, 'aws.region': awsRegion, region: awsRegion, 'aws.partition': getPartition(awsRegion), aws_service: awsService, 'aws.service': awsService, component: 'aws-sdk', } if (this.requestTags) this.requestTags.set(request, meta) const span = this.startSpan(this.operationFromRequest(request), { childOf, meta, service: this.serviceName(), integrationName: 'aws-sdk', }, ctx) analyticsSampler.sample(span, this.config.measured) storage('legacy').run(ctx.currentStore, () => { this.requestInject(span, request) }) if (this.constructor.isPayloadReporter && this.cloudTaggingConfig.request) { const maxDepth = this.cloudTaggingConfig.maxDepth const requestTags = tagsFromRequest(this.payloadTaggingRules, request.params, { maxDepth }) span.addTags(requestTags) } return ctx.currentStore }) this.addSub(`apm:aws:request:start:${this.serviceIdentifier}`, (ctx) => { if (!IS_SERVERLESS) return const { awsRegion, awsService, currentStore, request } = ctx const peerServerlessStorage = storage('peerServerless') // Try to resolve the hostname immediately; if not possible, keep enough // information so the region callback can resolve it later. const hostname = getHostname({ awsParams: request.params, awsService }, awsRegion) const peerServerlessStore = {} peerServerlessStorage.enterWith(peerServerlessStore) if (hostname) { currentStore.span.setTag('peer.service', hostname) peerServerlessStore.peerHostname = hostname } else { currentStore.awsParams = request.params currentStore.awsService = awsService } }) this.addSub(`apm:aws:request:region:${this.serviceIdentifier}`, ({ region }) => { const store = storage('legacy').getStore() if (!store) return const { span } = store if (!span) return span.setTag('aws.region', region) span.setTag('region', region) const partition = getPartition(region) if (partition) { span.setTag('aws.partition', partition) } if (!IS_SERVERLESS) return const hostname = getHostname(store, region) if (!hostname) return span.setTag('peer.service', hostname) const peerServerlessStore = storage('peerServerless').getStore() if (peerServerlessStore) { peerServerlessStore.peerHostname = hostname } }) this.addSub(`apm:aws:request:complete:${this.serviceIdentifier}`, ctx => { const { response, cbExists = false, currentStore } = ctx if (!currentStore) return const { span } = currentStore if (!span) return storage('legacy').run(currentStore, () => { // try to extract DSM context from response if no callback exists as extraction normally happens in CB if (!cbExists && this.serviceIdentifier === 'sqs') { const params = response.request.params const operation = response.request.operation this.responseExtractDSMContext(operation, params, response.data ?? response, span) } this.addResponseTags(span, response) if (this._tracerConfig?.DD_TRACE_AWS_ADD_SPAN_POINTERS) { this.addSpanPointers(span, response) } }) this.finish(ctx) if (IS_SERVERLESS) { const peerStore = storage('peerServerless').getStore() if (peerStore) delete peerStore.peerHostname } }) this.addBind(`apm:aws:response:start:${this.serviceIdentifier}`, ctx => { return this._parentMap.get(ctx.request) }) } requestInject (span, request) { // implemented by subclasses, or not } addSpanPointers (span, response) { // Optionally implemented by subclasses, for services where we're unable to inject trace context } operationFromRequest (request) { // can be overriden by subclasses return this.operationName({ id: 'aws', type: 'web', kind: 'client', awsService: this.serviceIdentifier, }) } serviceName () { return this.config.service || super.serviceName({ id: 'aws', type: 'web', kind: 'client', awsService: this.serviceIdentifier, }) } isEnabled (request) { const serviceId = this.serviceIdentifier.toUpperCase() return this._tracerConfig[`DD_TRACE_AWS_SDK_${serviceId}_ENABLED`] ?? true } addResponseTags (span, response) { if (!span || !response.request) return const params = response.request.params const operation = response.request.operation // `'span.kind': 'client'` is already set by the start-meta; SQS overrides via `generateTags`. span.setTag('aws.response.request_id', response.requestId) span.setTag('resource.name', operation) const extraTags = this.generateTags(params, operation, response) if (extraTags) { span.addTags(extraTags) } if (this.constructor.isPayloadReporter && this.cloudTaggingConfig.response) { const maxDepth = this.cloudTaggingConfig.maxDepth const responseBody = this.extractResponseBody(response) const responseTags = tagsFromResponse(this.payloadTaggingRules, responseBody, { maxDepth }) span.addTags(responseTags) } } extractResponseBody (response) { if (Object.hasOwn(response, 'data')) { return response.data } // `{ ...response }` followed by `delete body.X` allocates a copy and then // pushes the copy into V8 dictionary mode for every SDK response. Filter // on build instead -- ~2.3x faster on the typical 4-of-8-keys shape. const body = {} for (const key of Object.keys(response)) { if (!RESPONSE_SKIP_KEYS.has(key)) { body[key] = response[key] } } return body } generateTags () { // implemented by subclasses, or not } finish (ctx) { const { currentStore, response } = ctx const { span } = currentStore const error = response?.error || ctx.error if (error) { span.setTag('error', error) const requestId = error.RequestId || error.requestId if (requestId) { span.addTags({ 'aws.response.request_id': requestId }) } } if (response) { this.config.hooks.request(span, response) } super.finish(ctx) } configure (config) { super.configure(normalizeConfig(config, this.serviceIdentifier)) } } function normalizeConfig (config, serviceIdentifier) { const hooks = getHooks(config) let specificConfig = config[serviceIdentifier] if (typeof specificConfig === 'boolean') { specificConfig = { enabled: specificConfig } } // Check if AWS batch propagation or AWS_[SERVICE] batch propagation is enabled via env variable const tracerConfig = getConfig() const serviceId = serviceIdentifier.toUpperCase() const serviceBatchKey = /** @type {import('../../dd-trace/src/config/config-types').ConfigPath} */( `DD_TRACE_AWS_SDK_${serviceId}_BATCH_PROPAGATION_ENABLED` ) const batchPropagationEnabled = tracerConfig.getOrigin(serviceBatchKey) === 'default' ? tracerConfig.DD_TRACE_AWS_SDK_BATCH_PROPAGATION_ENABLED : tracerConfig[serviceBatchKey] // Merge the specific config back into the main config return { batchPropagationEnabled, ...config, ...specificConfig, hooks, } } const noop = () => {} function getHooks (config) { const request = config.hooks?.request || noop return { request } } function getHostname (store, region) { if (!store) return if (!region) return const { awsParams, awsService } = store switch (awsService) { case 'EventBridge': return `events.${region}.amazonaws.com` case 'SQS': return `sqs.${region}.amazonaws.com` case 'SNS': return `sns.${region}.amazonaws.com` case 'Kinesis': return `kinesis.${region}.amazonaws.com` case 'DynamoDBDocument': case 'DynamoDB': return `dynamodb.${region}.amazonaws.com` case 'S3': return awsParams?.Bucket ? `${awsParams.Bucket}.s3.${region}.amazonaws.com` : `s3.${region}.amazonaws.com` } } function getPartition (region) { if (!region) return let partition = 'aws' if (region.startsWith('cn-')) { partition = 'aws-cn' } else if (region.startsWith('us-gov-')) { partition = 'aws-us-gov' } return partition } module.exports = BaseAwsSdkPlugin