Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:**

Expand All @@ -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)

Expand Down
53 changes: 46 additions & 7 deletions lib/errors.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,66 @@
'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
* @param {object} [options]
* @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
};
171 changes: 108 additions & 63 deletions lib/reporters.js
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably want to either merge this into DatadogReporter or pull some more logic from it (e.g. base URL stuff) down into this class.

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;
}

Expand All @@ -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
);
}

Expand All @@ -87,16 +97,16 @@ 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.
// There is also an upcoming IETF standard for 'ratelimit', but it
// 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;
Expand All @@ -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}`;
}
}

/**
Expand All @@ -117,8 +138,8 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary {
* wait this long multiplied by 2^(retry count).
*/

/** @type {WeakMap<DatadogReporter, datadogApiClient.v1.MetricsApi>} */
const datadogClients = new WeakMap();
/** @type {WeakMap<DatadogReporter, string>} */
const datadogApiKeys = new WeakMap();

/**
* Create a reporter that sends metrics to Datadog's API.
Expand All @@ -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);
}

/**
Expand All @@ -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 ' +
Expand All @@ -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);
}
}

/**
Expand Down
Loading