diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ca9684..758bf57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: matrix: # Test supported release Node.js versions (even numbers) plus current # development version. - node_version: [12, 14, 16, 18, 20, 22, 23] + node_version: [14, 16, 18, 20, 22, 24] steps: - uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc36827..5f4dbdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ For changes to this repo, follow the [local development](#local-development) ste 1. If you don't have commit rights to this repo, [fork it][fork]. -2. Install Node.js 12 or newer. +2. Install Node.js 14 or newer. 3. Clone your fork (or this repo if you have commit rights) to your local development machine: diff --git a/README.md b/README.md index 0325b65..b667785 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The downside of using the HTTP API is that it can negatively affect your app's p ## Installation -Datadog-metrics is compatible with Node.js v12 and later. You can install it with NPM: +Datadog-metrics is compatible with Node.js v14 and later. You can install it with NPM: ```sh npm install datadog-metrics --save @@ -371,7 +371,9 @@ Contributions are always welcome! For more info on how to contribute or develop **Breaking Changes:** -TBD +* The minimum required Node.js version is now v14.0.0. + +* The `code` property on `AuthorizationError` instances has been changed to `DATADOG_METRICS_AUTHORIZATION_ERROR` to make names more clear and consistent (it was previously `DATADOG_AUTHORIZATION_ERROR`). If you are using `errorInstance.code` to check types, make sure to update the string you are looking for. **New Features:** @@ -387,7 +389,7 @@ TBD **Maintenance:** -TBD +* Under the hood, we’ve removed a dependency on the official Datadog client (`@datadog/datadog-api-client`). This is an attempt to streamline the package, since the official client comes at a sizeable 15 MB of code for you to download and then load in your application. (#144) [View diff](https://github.com/dbader/node-datadog-metrics/compare/v0.12.1...main) diff --git a/lib/errors.js b/lib/errors.js index 6ab2d09..30ddcc5 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -1,13 +1,51 @@ 'use strict'; +/** + * Base class for errors from datadog-metrics. + * @property {'DATADOG_METRICS_ERROR'} code + */ +class DatadogMetricsError extends Error { + constructor(message, options = {}) { + // @ts-expect-error the ECMAScript version we target with TypeScript + // does not include `error.cause` (new in ES 2022), but all versions of + // Node.js we support do. + super(message, { cause: options.cause }); + this.code = 'DATADOG_METRICS_ERROR'; + } +} + +/** + * Represents an HTTP error response from the Datadog API. + * + * @property {'DATADOG_METRICS_HTTP_ERROR'} code + * @property {number} status The HTTP status code. + */ +class HttpError extends DatadogMetricsError { + /** + * Create a `HttpError`. + * @param {string} message + * @param {object} options + * @param {any} options.response + * @param {any} [options.body] + * @param {Error} [options.cause] + */ + constructor (message, options) { + super(message, { cause: options.cause }); + this.code = 'DATADOG_METRICS_HTTP_ERROR'; + this.response = options.response; + this.body = options.body; + this.status = this.response.status; + } +} + /** * Represents an authorization failure response from the Datadog API, usually * because of an invalid API key. * - * @property {'DATADOG_AUTHORIZATION_ERROR'} code + * @property {'DATADOG_METRICS_AUTHORIZATION_ERROR'} code * @property {number} status */ -class AuthorizationError extends Error { +class AuthorizationError extends DatadogMetricsError { /** * Create an `AuthorizationError`. * @param {string} message @@ -15,13 +53,14 @@ class AuthorizationError extends Error { * @param {Error} [options.cause] */ constructor(message, options = {}) { - // @ts-expect-error the ECMAScript version we target with TypeScript - // does not include `error.cause` (new in ES 2022), but all versions of - // Node.js we support do. super(message, { cause: options.cause }); - this.code = 'DATADOG_AUTHORIZATION_ERROR'; + this.code = 'DATADOG_METRICS_AUTHORIZATION_ERROR'; this.status = 403; } } -module.exports = { AuthorizationError }; +module.exports = { + DatadogMetricsError, + HttpError, + AuthorizationError +}; diff --git a/lib/reporters.js b/lib/reporters.js index f74822e..ebde40f 100644 --- a/lib/reporters.js +++ b/lib/reporters.js @@ -1,6 +1,6 @@ 'use strict'; -const datadogApiClient = require('@datadog/datadog-api-client'); -const { AuthorizationError } = require('./errors'); +const { fetch } = require('cross-fetch'); +const { AuthorizationError, DatadogMetricsError, HttpError } = require('./errors'); const { logDebug, logDeprecation } = require('./logging'); const RETRYABLE_ERROR_CODES = new Set([ @@ -27,39 +27,49 @@ class NullReporter { /** * @private - * A custom HTTP implementation for Datadog that retries failed requests. - * Datadog has retries built in, but they don't handle network errors (just - * HTTP errors), and we want to retry in both cases. This inherits from the - * built-in HTTP library since we want to use the same fetch implementation - * Datadog uses instead of adding another dependency. + * Manages HTTP requests and associated retry/error handling logic. */ -class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { - constructor(options = {}) { - super(options); - - // HACK: ensure enableRetry is always `false` so the base class logic - // does not actually retry (since we manage retries here). - Object.defineProperty(this, 'enableRetry', { - get () { return false; }, - set () {}, - }); +class HttpApi { + constructor(options) { + this.maxRetries = options.maxRetries; + this.backoffBase = options.backoffBase; + this.backoffMultiplier = 2; } - async send(request) { + async send(url, options) { let i = 0; while (true) { // eslint-disable-line no-constant-condition - let response, error; + let response, body, error; try { - response = await super.send(request); + logDebug(`Sending HTTP request to "${url}"`); + response = await fetch(url, options); + body = await response.json(); } catch (e) { error = e; } + const details = this.getLogDetails(url, response, error); if (this.isRetryable(response || error, i)) { - await sleep(this.retryDelay(response || error, i)); + const delay = this.retryDelay(response || error, i); + logDebug(`HTTP request failed, retrying in ${delay} ms. ${details}`); + + await sleep(delay); } else if (response) { - return response; + if (response.status >= 400) { + logDebug(`HTTP request failed. ${details}`); + + let message = `Could not fetch ${url}`; + if (body && body.errors) { + message += ` (${body.errors.join(', ')})`; + } + throw new HttpError(message, { response }); + } + + logDebug(`HTTP request succeeded. ${details}`); + return body; } else { + logDebug(`HTTP request failed. ${details}`); + throw error; } @@ -75,8 +85,8 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { isRetryable(response, tryCount) { return tryCount < this.maxRetries && ( RETRYABLE_ERROR_CODES.has(response.code) - || response.httpStatusCode === 429 - || response.httpStatusCode >= 500 + || response.status === 429 + || response.status >= 500 ); } @@ -87,7 +97,7 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { * @returns {number} */ retryDelay(response, tryCount) { - if (response.httpStatusCode === 429) { + if (response.status === 429) { // Datadog's official client supports just the 'x-ratelimit-reset' // header, so we support that here in addition to the standardized // 'retry-after' heaer. @@ -95,8 +105,8 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { // has moved away from the syntax used in 'x-ratelimit-reset'. This // stuff might change in the future. // https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ - const delayHeader = response.headers['retry-after'] - || response.headers['x-ratelimit-reset']; + const delayHeader = response.headers.get('retry-after') + || response.headers.get('x-ratelimit-reset'); const delayValue = parseInt(delayHeader, 10); if (!isNaN(delayValue) && delayValue > 0) { return delayValue * 1000; @@ -105,6 +115,17 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { return this.backoffMultiplier ** tryCount * this.backoffBase * 1000; } + + /** + * @private + * @param {string} url + * @param {Response?} response + * @param {Error?} error + */ + getLogDetails(url, response, error) { + let result = response ? `HTTP status: ${response.status}` : `error: ${error}`; + return `URL: "${url}", ${result}`; + } } /** @@ -117,8 +138,8 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary { * wait this long multiplied by 2^(retry count). */ -/** @type {WeakMap} */ -const datadogClients = new WeakMap(); +/** @type {WeakMap} */ +const datadogApiKeys = new WeakMap(); /** * Create a reporter that sends metrics to Datadog's API. @@ -142,43 +163,34 @@ class DatadogReporter { } const apiKey = options.apiKey || process.env.DATADOG_API_KEY || process.env.DD_API_KEY; - this.site = options.site - || process.env.DATADOG_SITE - || process.env.DD_SITE - || process.env.DATADOG_API_HOST; if (!apiKey) { - throw new Error( + throw new DatadogMetricsError( 'Datadog API key not found. You must specify one via the ' + '`apiKey` configuration option or the DATADOG_API_KEY or ' + 'DD_API_KEY environment variable.' ); } - const configuration = datadogApiClient.client.createConfiguration({ - authMethods: { - apiKeyAuth: apiKey, - }, - httpApi: new RetryHttp(), + /** @private @type {HttpApi} */ + this.httpApi = new HttpApi({ maxRetries: options.retries >= 0 ? options.retries : 2, + retryBackoff: options.retryBackoff >= 0 ? options.retryBackoff : 1 }); - // HACK: Specify backoff here rather than in configration options to - // support values less than 2 (mainly for faster tests). - const backoff = options.retryBackoff >= 0 ? options.retryBackoff : 1; - configuration.httpApi.backoffBase = backoff; - - if (this.site) { - // Strip leading `app.` from the site in case someone copy/pasted the - // URL from their web browser. More details on correct configuration: - // https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site - this.site = this.site.replace(/^app\./i, ''); - configuration.setServerVariables({ - site: this.site - }); - } + /** @private @type {string} */ + this.site = options.site + || process.env.DATADOG_SITE + || process.env.DD_SITE + || process.env.DATADOG_API_HOST + || 'datadoghq.com'; - datadogClients.set(this, new datadogApiClient.v1.MetricsApi(configuration)); + // Strip leading `app.` from the site in case someone copy/pasted the + // URL from their web browser. More details on correct configuration: + // https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site + this.site = this.site.replace(/^app\./i, ''); + + datadogApiKeys.set(this, apiKey); } /** @@ -201,25 +213,19 @@ class DatadogReporter { } } - const metricsApi = datadogClients.get(this); - let submissions = []; if (metrics.length) { - submissions.push(metricsApi.submitMetrics({ - body: { series: metrics } - })); + submissions.push(this.sendMetrics(metrics)); } if (distributions.length) { - submissions.push(metricsApi.submitDistributionPoints({ - body: { series: distributions } - })); + submissions.push(this.sendDistributions(distributions)); } try { await Promise.all(submissions); logDebug('sent metrics successfully'); } catch (error) { - if (error.code === 403) { + if (error.status === 403) { throw new AuthorizationError( 'Your Datadog API key is not authorized to send ' + 'metrics. Check to make sure the DATADOG_API_KEY or ' + @@ -235,6 +241,45 @@ class DatadogReporter { throw error; } } + + /** + * Send an array of metrics to the Datadog API. + * @private + * @param {any[]} series + * @returns {Promise} + */ + sendMetrics(series) { + return this.sendHttp('/v1/series', { body: { series } }); + } + + /** + * Send an array of distributions to the Datadog API. + * @private + * @param {any[]} series + * @returns {Promise} + */ + sendDistributions(series) { + return this.sendHttp('/v1/distribution_points', { body: { series } }); + } + + /** + * @private + * @param {string} path + * @param {any} options + * @returns {Promise} + */ + async sendHttp(path, options) { + const url = `https://api.${this.site}/api${path}`; + const fetchOptions = { + method: 'POST', + headers: { + 'DD-API-KEY': datadogApiKeys.get(this), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(options.body) + }; + return await this.httpApi.send(url, fetchOptions); + } } /** diff --git a/package.json b/package.json index 0834f36..2354cb7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "author": "Daniel Bader (http://dbader.org/)", "license": "MIT", "devDependencies": { - "@types/node": "^12.20.55", + "@datadog/datadog-api-client": "^1.31.0", + "@types/node": "^14.14.45", "chai": "4.3.6", "chai-as-promised": "^7.1.2", "chai-string": "1.5.0", @@ -39,11 +40,11 @@ "typescript": "^4.8.4" }, "dependencies": { - "@datadog/datadog-api-client": "^1.17.0", + "cross-fetch": "^4.0.0", "debug": "^4.1.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "files": [ "README.md", diff --git a/test/reporters_tests.js b/test/reporters_tests.js index dad02d3..4508e39 100644 --- a/test/reporters_tests.js +++ b/test/reporters_tests.js @@ -23,17 +23,23 @@ describe('NullReporter', function() { }); describe('DatadogReporter', function() { - afterEach(() => { - nock.cleanAll(); + let originalEnv = Object.entries(process.env) + .filter(([key, _]) => !/^(DD|DATADOG)_/.test(key)); + + before(() => { + nock.disableNetConnect(); }); - describe('constructor', function() { - let originalEnv = Object.entries(process.env); + after(() => { + nock.enableNetConnect(); + }); - afterEach(() => { - process.env = Object.fromEntries(originalEnv); - }); + beforeEach(() => { + nock.cleanAll(); + process.env = Object.fromEntries(originalEnv); + }); + describe('constructor', function() { it('creates a DatadogReporter', () => { const instance = new DatadogReporter({ apiKey: 'abc', diff --git a/tsconfig.json b/tsconfig.json index 4ede888..146bb49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "lib": ["es2019"], - "target": "es2019", + "lib": ["es2020"], + "target": "es2020", "module": "commonjs", "esModuleInterop": true,