UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

412 lines (323 loc) 9.99 kB
'use strict' const lookup = require('dns').lookup // cache to avoid instrumentation const request = require('./exporters/common/request') const dgram = require('dgram') const isIP = require('net').isIP const log = require('./log') const { URL, format } = require('url') const Histogram = require('./histogram') const MAX_BUFFER_SIZE = 1024 // limit from the agent const TYPE_COUNTER = 'c' const TYPE_GAUGE = 'g' const TYPE_DISTRIBUTION = 'd' const TYPE_HISTOGRAM = 'h' /** * @import { DogStatsD } from "../../../index.d.ts" * @implements {DogStatsD} */ class DogStatsDClient { constructor (options = {}) { if (options.metricsProxyUrl) { this._httpOptions = { url: options.metricsProxyUrl.toString(), path: '/dogstatsd/v2/proxy' } } this._host = options.host || 'localhost' this._family = isIP(this._host) this._port = options.port || 8125 this._prefix = options.prefix || '' this._tags = options.tags || [] this._queue = [] this._buffer = '' this._offset = 0 this._udp4 = this._socket('udp4') this._udp6 = this._socket('udp6') } increment (stat, value, tags) { this._add(stat, value, TYPE_COUNTER, tags) } decrement (stat, value, tags) { this._add(stat, -value, TYPE_COUNTER, tags) } gauge (stat, value, tags) { this._add(stat, value, TYPE_GAUGE, tags) } distribution (stat, value, tags) { this._add(stat, value, TYPE_DISTRIBUTION, tags) } histogram (stat, value, tags) { this._add(stat, value, TYPE_HISTOGRAM, tags) } flush () { const queue = this._enqueue() log.debug('Flushing %s metrics via', queue.length, this._httpOptions ? 'HTTP' : 'UDP') if (this._queue.length === 0) return this._queue = [] if (this._httpOptions) { this._sendHttp(queue) } else { this._sendUdp(queue) } } _sendHttp (queue) { const buffer = Buffer.concat(queue) request(buffer, this._httpOptions, (err) => { if (err) { log.error('DogStatsDClient: HTTP error from agent: %s', err.message, err) if (err.status === 404) { // Inside this if-block, we have connectivity to the agent, but // we're not getting a 200 from the proxy endpoint. If it's a 404, // then we know we'll never have the endpoint, so just clear out the // options. Either way, we can give UDP a try. this._httpOptions = undefined } this._sendUdp(queue) } }) } _sendUdp (queue) { if (this._family === 0) { lookup(this._host, (err, address, family) => { if (err) return log.error('DogStatsDClient: Host not found', err) this._sendUdpFromQueue(queue, address, family) }) } else { this._sendUdpFromQueue(queue, this._host, this._family) } } _sendUdpFromQueue (queue, address, family) { const socket = family === 6 ? this._udp6 : this._udp4 queue.forEach((buffer) => { log.debug('Sending to DogStatsD: %s', buffer) socket.send(buffer, 0, buffer.length, this._port, address) }) } _add (stat, value, type, tags) { const message = `${this._prefix + stat}:${value}|${type}` // Don't manipulate this._tags as it is still used tags = tags ? [...this._tags, ...tags] : this._tags if (tags.length > 0) { this._write(`${message}|#${tags.join(',')}\n`) } else { this._write(`${message}\n`) } } _write (message) { const offset = Buffer.byteLength(message) if (this._offset + offset > MAX_BUFFER_SIZE) { this._enqueue() } this._offset += offset this._buffer += message } _enqueue () { if (this._offset > 0) { this._queue.push(Buffer.from(this._buffer)) this._buffer = '' this._offset = 0 } return this._queue } _socket (type) { const socket = dgram.createSocket(type) socket.on('error', () => {}) socket.unref() return socket } static generateClientConfig (config) { const tags = [] if (config.tags) { for (const [key, value] of Object.entries(config.tags)) { // Skip runtime-id unless enabled as cardinality may be too high if (typeof value === 'string' && (key !== 'runtime-id' || config.runtimeMetricsRuntimeId)) { // https://docs.datadoghq.com/tagging/#defining-tags const valueStripped = value.replaceAll(/[^a-z0-9_:./-]/ig, '_') tags.push(`${key}:${valueStripped}`) } } } const clientConfig = { host: config.dogstatsd.hostname, port: config.dogstatsd.port, tags } if (config.url) { clientConfig.metricsProxyUrl = config.url } else if (config.port) { clientConfig.metricsProxyUrl = new URL(format({ protocol: 'http:', hostname: config.hostname || 'localhost', port: config.port })) } return clientConfig } } class MetricsAggregationClient { constructor (client) { this._client = client this.reset() } flush () { this._captureCounters() this._captureGauges() this._captureHistograms() this._client.flush() } reset () { this._counters = new Map() this._gauges = new Map() this._histograms = new Map() } // TODO: Aggregate with a histogram and send the buckets to the client. distribution (name, value, tags) { this._client.distribution(name, value, tags) } boolean (name, value, tags) { this.gauge(name, value ? 1 : 0, tags) } histogram (name, value, tags) { const node = this._ensureTree(this._histograms, name, tags, null) if (!node.value) { node.value = new Histogram() } node.value.record(value) } count (name, count, tags = [], monotonic = true) { if (typeof tags === 'boolean') { monotonic = tags tags = [] } const container = monotonic ? this._counters : this._gauges const node = this._ensureTree(container, name, tags, 0) node.value += count } gauge (name, value, tags) { const node = this._ensureTree(this._gauges, name, tags, 0) node.value = value } increment (name, count = 1, tags) { this.count(name, count, tags) } decrement (name, count = 1, tags) { this.count(name, -count, tags) } _captureGauges () { this._captureTree(this._gauges, (node, name, tags) => { this._client.gauge(name, node.value, tags) }) } _captureCounters () { this._captureTree(this._counters, (node, name, tags) => { this._client.increment(name, node.value, tags) }) this._counters.clear() } _captureHistograms () { this._captureTree(this._histograms, (node, name, tags) => { let stats = node.value // Stats can contain garbage data when a value was never recorded. if (stats.count === 0) { stats = { max: 0, min: 0, sum: 0, avg: 0, median: 0, p95: 0, count: 0 } } this._client.gauge(`${name}.min`, stats.min, tags) this._client.gauge(`${name}.max`, stats.max, tags) this._client.increment(`${name}.sum`, stats.sum, tags) this._client.increment(`${name}.total`, stats.sum, tags) this._client.gauge(`${name}.avg`, stats.avg, tags) this._client.increment(`${name}.count`, stats.count, tags) this._client.gauge(`${name}.median`, stats.median, tags) this._client.gauge(`${name}.95percentile`, stats.p95, tags) node.value.reset() }) } _captureTree (tree, fn) { for (const [name, root] of tree) { this._captureNode(root, name, [], fn) } } _captureNode (node, name, tags, fn) { if (node.touched) { fn(node, name, tags) } for (const [tag, next] of node.nodes) { tags.push(tag) this._captureNode(next, name, tags, fn) tags.pop() } } _ensureTree (tree, name, tags = [], value) { if (!Array.isArray(tags)) { tags = [tags] } let node = this._ensureNode(tree, name, value) for (const tag of tags) { node = this._ensureNode(node.nodes, tag, value) } node.touched = true return node } _ensureNode (container, key, value) { let node = container.get(key) if (!node) { node = { nodes: new Map(), touched: false, value } if (typeof key === 'string') { container.set(key, node) } } return node } } /** * This is a simplified user-facing proxy to the underlying DogStatsDClient instance * * @implements {DogStatsD} */ class CustomMetrics { #client constructor (config) { const clientConfig = DogStatsDClient.generateClientConfig(config) this.#client = new MetricsAggregationClient(new DogStatsDClient(clientConfig)) const flush = this.flush.bind(this) // TODO(bengl) this magic number should be configurable setInterval(flush, 10 * 1000).unref() process.once('beforeExit', flush) } increment (stat, value = 1, tags) { this.#client.increment(stat, value, CustomMetrics.tagTranslator(tags)) } decrement (stat, value = 1, tags) { this.#client.decrement(stat, value, CustomMetrics.tagTranslator(tags)) } gauge (stat, value, tags) { this.#client.gauge(stat, value, CustomMetrics.tagTranslator(tags)) } distribution (stat, value, tags) { this.#client.distribution(stat, value, CustomMetrics.tagTranslator(tags)) } histogram (stat, value, tags) { this.#client.histogram(stat, value, CustomMetrics.tagTranslator(tags)) } flush () { return this.#client.flush() } /** * Exposing { tagName: 'tagValue' } to the end user * These are translated into [ 'tagName:tagValue' ] for internal use */ static tagTranslator (objTags) { if (Array.isArray(objTags)) return objTags const arrTags = [] if (!objTags) return arrTags for (const [key, value] of Object.entries(objTags)) { arrTags.push(`${key}:${value}`) } return arrTags } } module.exports = { DogStatsDClient, CustomMetrics, MetricsAggregationClient }