dd-trace
Version:
Datadog APM tracing client for JavaScript
369 lines (301 loc) • 10.7 kB
JavaScript
'use strict'
const { URL, format } = require('url')
const uuid = require('../../../../vendor/dist/crypto-randomuuid')
const { EventEmitter } = require('events')
const tracerVersion = require('../../../../package.json').version
const request = require('../exporters/common/request')
const log = require('../log')
const { getExtraServices } = require('../service-naming/extra-services')
const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('./apply_states')
const Scheduler = require('./scheduler')
const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../plugins/util/tags')
const tagger = require('../tagger')
const defaults = require('../config_defaults')
const clientId = uuid()
const DEFAULT_CAPABILITY = Buffer.alloc(1).toString('base64') // 0x00
const kPreUpdate = Symbol('kPreUpdate')
const kSupportsAckCallback = Symbol('kSupportsAckCallback')
// There MUST NOT exist separate instances of RC clients in a tracer making separate ClientGetConfigsRequest
// with their own separated Client.ClientState.
class RemoteConfigManager extends EventEmitter {
static kPreUpdate = kPreUpdate
constructor (config) {
super()
const pollInterval = Math.floor(config.remoteConfig.pollInterval * 1000)
this.url = config.url || new URL(format({
protocol: 'http:',
hostname: config.hostname || defaults.hostname,
port: config.port
}))
tagger.add(config.tags, {
'_dd.rc.client_id': clientId
})
const tags = config.repositoryUrl
? {
...config.tags,
[GIT_REPOSITORY_URL]: config.repositoryUrl,
[GIT_COMMIT_SHA]: config.commitSHA
}
: config.tags
this._handlers = new Map()
const appliedConfigs = this.appliedConfigs = new Map()
this.scheduler = new Scheduler((cb) => this.poll(cb), pollInterval)
this.state = {
client: {
state: { // updated by `parseConfig()` and `poll()`
root_version: 1,
targets_version: 0,
// Use getter so `apply_*` can be updated async and still affect the content of `config_states`
get config_states () {
const configs = []
for (const conf of appliedConfigs.values()) {
configs.push({
id: conf.id,
version: conf.version,
product: conf.product,
apply_state: conf.apply_state,
apply_error: conf.apply_error
})
}
return configs
},
has_error: false,
error: '',
backend_client_state: ''
},
id: clientId,
products: [], // updated by `updateProducts()`
is_tracer: true,
client_tracer: {
runtime_id: config.tags['runtime-id'],
language: 'node',
tracer_version: tracerVersion,
service: config.service,
env: config.env,
app_version: config.version,
extra_services: [],
tags: Object.entries(tags).map((pair) => pair.join(':'))
},
capabilities: DEFAULT_CAPABILITY // updated by `updateCapabilities()`
},
cached_target_files: [] // updated by `parseConfig()`
}
}
updateCapabilities (mask, value) {
const hex = Buffer.from(this.state.client.capabilities, 'base64').toString('hex')
let num = BigInt(`0x${hex}`)
if (value) {
num |= mask
} else {
num &= ~mask
}
let str = num.toString(16)
if (str.length % 2) str = `0${str}`
this.state.client.capabilities = Buffer.from(str, 'hex').toString('base64')
}
setProductHandler (product, handler) {
this._handlers.set(product, handler)
this.updateProducts()
if (this.state.client.products.length === 1) {
this.scheduler.start()
}
}
removeProductHandler (product) {
this._handlers.delete(product)
this.updateProducts()
if (this.state.client.products.length === 0) {
this.scheduler.stop()
}
}
updateProducts () {
this.state.client.products = [...this._handlers.keys()]
}
getPayload () {
this.state.client.client_tracer.extra_services = getExtraServices()
return JSON.stringify(this.state)
}
poll (cb) {
const options = {
url: this.url,
method: 'POST',
path: '/v0.7/config',
headers: {
'Content-Type': 'application/json; charset=utf-8'
}
}
request(this.getPayload(), options, (err, data, statusCode) => {
// 404 means RC is disabled, ignore it
if (statusCode === 404) return cb()
if (err) {
log.errorWithoutTelemetry('[RC] Error in request', err)
return cb()
}
// if error was just sent, reset the state
if (this.state.client.state.has_error) {
this.state.client.state.has_error = false
this.state.client.state.error = ''
}
if (data && data !== '{}') { // '{}' means the tracer is up to date
try {
this.parseConfig(JSON.parse(data))
} catch (err) {
log.error('[RC] Could not parse remote config response', err)
this.state.client.state.has_error = true
this.state.client.state.error = err.toString()
}
}
cb()
})
}
// `client_configs` is the list of config paths to have applied
// `targets` is the signed index with metadata for config files
// `target_files` is the list of config files containing the actual config data
parseConfig ({
client_configs: clientConfigs = [],
targets,
target_files: targetFiles = []
}) {
const toUnapply = []
const toApply = []
const toModify = []
for (const appliedConfig of this.appliedConfigs.values()) {
if (!clientConfigs.includes(appliedConfig.path)) {
toUnapply.push(appliedConfig)
}
}
targets = fromBase64JSON(targets)
if (targets) {
for (const path of clientConfigs) {
const meta = targets.signed.targets[path]
if (!meta) throw new Error(`Unable to find target for path ${path}`)
const current = this.appliedConfigs.get(path)
const newConf = {}
if (current) {
if (current.hashes.sha256 === meta.hashes.sha256) continue
toModify.push(newConf)
} else {
toApply.push(newConf)
}
const file = targetFiles.find(file => file.path === path)
if (!file) throw new Error(`Unable to find file for path ${path}`)
// TODO: verify signatures
// verify length
// verify hash
// verify _type
// TODO: new Date(meta.signed.expires) ignore the Targets data if it has expired ?
const { product, id } = parseConfigPath(path)
Object.assign(newConf, {
path,
product,
id,
version: meta.custom.v,
apply_state: UNACKNOWLEDGED,
apply_error: '',
length: meta.length,
hashes: meta.hashes,
file: fromBase64JSON(file.raw)
})
}
this.state.client.state.targets_version = targets.signed.version
this.state.client.state.backend_client_state = targets.signed.custom.opaque_backend_state
}
if (toUnapply.length || toApply.length || toModify.length) {
this.emit(RemoteConfigManager.kPreUpdate, { toUnapply, toApply, toModify })
this.dispatch(toUnapply, 'unapply')
this.dispatch(toApply, 'apply')
this.dispatch(toModify, 'modify')
this.state.cached_target_files = []
for (const conf of this.appliedConfigs.values()) {
const hashes = []
for (const hash of Object.entries(conf.hashes)) {
hashes.push({ algorithm: hash[0], hash: hash[1] })
}
this.state.cached_target_files.push({
path: conf.path,
length: conf.length,
hashes
})
}
}
}
dispatch (list, action) {
for (const item of list) {
// TODO: we need a way to tell if unapply configs were handled by kPreUpdate or not, because they're always
// emitted unlike the apply and modify configs
this._callHandlerFor(action, item)
if (action === 'unapply') {
this.appliedConfigs.delete(item.path)
} else {
this.appliedConfigs.set(item.path, item)
}
}
}
_callHandlerFor (action, item) {
// in case the item was already handled by kPreUpdate
if (item.apply_state !== UNACKNOWLEDGED && action !== 'unapply') return
const handler = this._handlers.get(item.product)
if (!handler) return
try {
if (supportsAckCallback(handler)) {
// If the handler accepts an `ack` callback, expect that to be called and set `apply_state` accordinly
// TODO: do we want to pass old and new config ?
handler(action, item.file, item.id, (err) => {
if (err) {
item.apply_state = ERROR
item.apply_error = err.toString()
} else if (item.apply_state !== ERROR) {
item.apply_state = ACKNOWLEDGED
}
})
} else {
// If the handler doesn't accept an `ack` callback, assume `apply_state` is `ACKNOWLEDGED`,
// unless it returns a promise, in which case we wait for the promise to be resolved or rejected.
// TODO: do we want to pass old and new config ?
const result = handler(action, item.file, item.id)
if (result instanceof Promise) {
result.then(
() => { item.apply_state = ACKNOWLEDGED },
(err) => {
item.apply_state = ERROR
item.apply_error = err.toString()
}
)
} else {
item.apply_state = ACKNOWLEDGED
}
}
} catch (err) {
item.apply_state = ERROR
item.apply_error = err.toString()
}
}
}
function fromBase64JSON (str) {
if (!str) return null
return JSON.parse(Buffer.from(str, 'base64').toString())
}
const configPathRegex = /^(?:datadog\/\d+|employee)\/([^/]+)\/([^/]+)\/[^/]+$/
function parseConfigPath (configPath) {
const match = configPathRegex.exec(configPath)
if (!match || !match[1] || !match[2]) {
throw new Error(`Unable to parse path ${configPath}`)
}
return {
product: match[1],
id: match[2]
}
}
function supportsAckCallback (handler) {
if (kSupportsAckCallback in handler) return handler[kSupportsAckCallback]
const numOfArgs = handler.length
let result = false
if (numOfArgs >= 4) {
result = true
} else if (numOfArgs !== 0) {
const source = handler.toString()
result = source.slice(0, source.indexOf(')')).includes('...')
}
handler[kSupportsAckCallback] = result
return result
}
module.exports = RemoteConfigManager