Skip to content

Drop the official Datadog client #144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
50 changes: 45 additions & 5 deletions lib/errors.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,67 @@
'use strict';

/**
* Base class for errors from datadog-metrics.
* @property {'DATADOG_AUTHORIZATION_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 authorization failure response from the Datadog API, usually
* because of an invalid API key.
*
* @property {'DATADOG_HTTP_ERROR'} code
* @property {number} status The HTTP status code.
*/
class MetricsHttpError extends DatadogMetricsError {
/**
* Create a `MetricsHttpError`.
* @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_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 {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.status = 403;
}
}

module.exports = { AuthorizationError };
module.exports = {
DatadogMetricsError,
MetricsHttpError,
AuthorizationError
};
143 changes: 83 additions & 60 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, MetricsHttpError } = require('./errors');
const { logDebug, logDeprecation } = require('./logging');

const RETRYABLE_ERROR_CODES = new Set([
Expand All @@ -27,38 +27,37 @@ 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);
response = await fetch(url, options);
body = await response.json();
} catch (e) {
error = e;
}

if (this.isRetryable(response || error, i)) {
await sleep(this.retryDelay(response || error, i));
} else if (response) {
return response;
if (response.status >= 400) {
let message = `Could not fetch ${url}`;
if (body && body.errors) {
message += ` (${body.errors.join(', ')})`;
}
throw new MetricsHttpError(message, { response });
}
return body;
} else {
throw error;
}
Expand All @@ -75,8 +74,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 +86,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 @@ -117,8 +116,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,10 +141,6 @@ 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(
Expand All @@ -155,30 +150,25 @@ class DatadogReporter {
);
}

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;
/** @private @type {string} */
this.site = options.site
|| process.env.DATADOG_SITE
|| process.env.DD_SITE
|| process.env.DATADOG_API_HOST
|| 'datadoghq.com';

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
});
}
// 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, '');

datadogClients.set(this, new datadogApiClient.v1.MetricsApi(configuration));
datadogApiKeys.set(this, apiKey);
}

/**
Expand All @@ -201,25 +191,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 +219,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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"author": "Daniel Bader <mail@dbader.org> (http://dbader.org/)",
"license": "MIT",
"devDependencies": {
"@datadog/datadog-api-client": "^1.31.0",
"@types/node": "^12.20.55",
"chai": "4.3.6",
"chai-as-promised": "^7.1.2",
Expand All @@ -39,7 +40,7 @@
"typescript": "^4.8.4"
},
"dependencies": {
"@datadog/datadog-api-client": "^1.17.0",
"cross-fetch": "^3.2.0",
"debug": "^4.1.0"
},
"engines": {
Expand Down
20 changes: 13 additions & 7 deletions test/reporters_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading