Skip to content

Commit bae7265

Browse files
authored
Drop the official Datadog client (#144)
This drops our dependency on `@datadog/datadog-api-client` in favor of directly calling the API via HTTP. It requires a little more code on our side, but gets us a bit more flexibility and drops a 15 MB (!) dependency that we barely use. This partly covers #132. There is definitely followup work to do here, but we want to get this out the door so people can use it and provide feedback.
1 parent cbf67ee commit bae7265

File tree

8 files changed

+180
-87
lines changed

8 files changed

+180
-87
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
matrix:
1717
# Test supported release Node.js versions (even numbers) plus current
1818
# development version.
19-
node_version: [12, 14, 16, 18, 20, 22, 23]
19+
node_version: [14, 16, 18, 20, 22, 24]
2020

2121
steps:
2222
- uses: actions/checkout@v4

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ For changes to this repo, follow the [local development](#local-development) ste
3030

3131
1. If you don't have commit rights to this repo, [fork it][fork].
3232

33-
2. Install Node.js 12 or newer.
33+
2. Install Node.js 14 or newer.
3434

3535
3. Clone your fork (or this repo if you have commit rights) to your local development machine:
3636

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The downside of using the HTTP API is that it can negatively affect your app's p
1111

1212
## Installation
1313

14-
Datadog-metrics is compatible with Node.js v12 and later. You can install it with NPM:
14+
Datadog-metrics is compatible with Node.js v14 and later. You can install it with NPM:
1515

1616
```sh
1717
npm install datadog-metrics --save
@@ -371,7 +371,9 @@ Contributions are always welcome! For more info on how to contribute or develop
371371

372372
**Breaking Changes:**
373373

374-
TBD
374+
* The minimum required Node.js version is now v14.0.0.
375+
376+
* 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.
375377

376378
**New Features:**
377379

@@ -387,7 +389,7 @@ TBD
387389

388390
**Maintenance:**
389391

390-
TBD
392+
* 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)
391393

392394
[View diff](https://github.com/dbader/node-datadog-metrics/compare/v0.12.1...main)
393395

lib/errors.js

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,66 @@
11
'use strict';
22

3+
/**
4+
* Base class for errors from datadog-metrics.
5+
* @property {'DATADOG_METRICS_ERROR'} code
6+
*/
7+
class DatadogMetricsError extends Error {
8+
constructor(message, options = {}) {
9+
// @ts-expect-error the ECMAScript version we target with TypeScript
10+
// does not include `error.cause` (new in ES 2022), but all versions of
11+
// Node.js we support do.
12+
super(message, { cause: options.cause });
13+
this.code = 'DATADOG_METRICS_ERROR';
14+
}
15+
}
16+
17+
/**
18+
* Represents an HTTP error response from the Datadog API.
19+
*
20+
* @property {'DATADOG_METRICS_HTTP_ERROR'} code
21+
* @property {number} status The HTTP status code.
22+
*/
23+
class HttpError extends DatadogMetricsError {
24+
/**
25+
* Create a `HttpError`.
26+
* @param {string} message
27+
* @param {object} options
28+
* @param {any} options.response
29+
* @param {any} [options.body]
30+
* @param {Error} [options.cause]
31+
*/
32+
constructor (message, options) {
33+
super(message, { cause: options.cause });
34+
this.code = 'DATADOG_METRICS_HTTP_ERROR';
35+
this.response = options.response;
36+
this.body = options.body;
37+
this.status = this.response.status;
38+
}
39+
}
40+
341
/**
442
* Represents an authorization failure response from the Datadog API, usually
543
* because of an invalid API key.
644
*
7-
* @property {'DATADOG_AUTHORIZATION_ERROR'} code
45+
* @property {'DATADOG_METRICS_AUTHORIZATION_ERROR'} code
846
* @property {number} status
947
*/
10-
class AuthorizationError extends Error {
48+
class AuthorizationError extends DatadogMetricsError {
1149
/**
1250
* Create an `AuthorizationError`.
1351
* @param {string} message
1452
* @param {object} [options]
1553
* @param {Error} [options.cause]
1654
*/
1755
constructor(message, options = {}) {
18-
// @ts-expect-error the ECMAScript version we target with TypeScript
19-
// does not include `error.cause` (new in ES 2022), but all versions of
20-
// Node.js we support do.
2156
super(message, { cause: options.cause });
22-
this.code = 'DATADOG_AUTHORIZATION_ERROR';
57+
this.code = 'DATADOG_METRICS_AUTHORIZATION_ERROR';
2358
this.status = 403;
2459
}
2560
}
2661

27-
module.exports = { AuthorizationError };
62+
module.exports = {
63+
DatadogMetricsError,
64+
HttpError,
65+
AuthorizationError
66+
};

lib/reporters.js

Lines changed: 108 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
2-
const datadogApiClient = require('@datadog/datadog-api-client');
3-
const { AuthorizationError } = require('./errors');
2+
const { fetch } = require('cross-fetch');
3+
const { AuthorizationError, DatadogMetricsError, HttpError } = require('./errors');
44
const { logDebug, logDeprecation } = require('./logging');
55

66
const RETRYABLE_ERROR_CODES = new Set([
@@ -27,39 +27,49 @@ class NullReporter {
2727

2828
/**
2929
* @private
30-
* A custom HTTP implementation for Datadog that retries failed requests.
31-
* Datadog has retries built in, but they don't handle network errors (just
32-
* HTTP errors), and we want to retry in both cases. This inherits from the
33-
* built-in HTTP library since we want to use the same fetch implementation
34-
* Datadog uses instead of adding another dependency.
30+
* Manages HTTP requests and associated retry/error handling logic.
3531
*/
36-
class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary {
37-
constructor(options = {}) {
38-
super(options);
39-
40-
// HACK: ensure enableRetry is always `false` so the base class logic
41-
// does not actually retry (since we manage retries here).
42-
Object.defineProperty(this, 'enableRetry', {
43-
get () { return false; },
44-
set () {},
45-
});
32+
class HttpApi {
33+
constructor(options) {
34+
this.maxRetries = options.maxRetries;
35+
this.backoffBase = options.backoffBase;
36+
this.backoffMultiplier = 2;
4637
}
4738

48-
async send(request) {
39+
async send(url, options) {
4940
let i = 0;
5041
while (true) { // eslint-disable-line no-constant-condition
51-
let response, error;
42+
let response, body, error;
5243
try {
53-
response = await super.send(request);
44+
logDebug(`Sending HTTP request to "${url}"`);
45+
response = await fetch(url, options);
46+
body = await response.json();
5447
} catch (e) {
5548
error = e;
5649
}
5750

51+
const details = this.getLogDetails(url, response, error);
5852
if (this.isRetryable(response || error, i)) {
59-
await sleep(this.retryDelay(response || error, i));
53+
const delay = this.retryDelay(response || error, i);
54+
logDebug(`HTTP request failed, retrying in ${delay} ms. ${details}`);
55+
56+
await sleep(delay);
6057
} else if (response) {
61-
return response;
58+
if (response.status >= 400) {
59+
logDebug(`HTTP request failed. ${details}`);
60+
61+
let message = `Could not fetch ${url}`;
62+
if (body && body.errors) {
63+
message += ` (${body.errors.join(', ')})`;
64+
}
65+
throw new HttpError(message, { response });
66+
}
67+
68+
logDebug(`HTTP request succeeded. ${details}`);
69+
return body;
6270
} else {
71+
logDebug(`HTTP request failed. ${details}`);
72+
6373
throw error;
6474
}
6575

@@ -75,8 +85,8 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary {
7585
isRetryable(response, tryCount) {
7686
return tryCount < this.maxRetries && (
7787
RETRYABLE_ERROR_CODES.has(response.code)
78-
|| response.httpStatusCode === 429
79-
|| response.httpStatusCode >= 500
88+
|| response.status === 429
89+
|| response.status >= 500
8090
);
8191
}
8292

@@ -87,16 +97,16 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary {
8797
* @returns {number}
8898
*/
8999
retryDelay(response, tryCount) {
90-
if (response.httpStatusCode === 429) {
100+
if (response.status === 429) {
91101
// Datadog's official client supports just the 'x-ratelimit-reset'
92102
// header, so we support that here in addition to the standardized
93103
// 'retry-after' heaer.
94104
// There is also an upcoming IETF standard for 'ratelimit', but it
95105
// has moved away from the syntax used in 'x-ratelimit-reset'. This
96106
// stuff might change in the future.
97107
// https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
98-
const delayHeader = response.headers['retry-after']
99-
|| response.headers['x-ratelimit-reset'];
108+
const delayHeader = response.headers.get('retry-after')
109+
|| response.headers.get('x-ratelimit-reset');
100110
const delayValue = parseInt(delayHeader, 10);
101111
if (!isNaN(delayValue) && delayValue > 0) {
102112
return delayValue * 1000;
@@ -105,6 +115,17 @@ class RetryHttp extends datadogApiClient.client.IsomorphicFetchHttpLibrary {
105115

106116
return this.backoffMultiplier ** tryCount * this.backoffBase * 1000;
107117
}
118+
119+
/**
120+
* @private
121+
* @param {string} url
122+
* @param {Response?} response
123+
* @param {Error?} error
124+
*/
125+
getLogDetails(url, response, error) {
126+
let result = response ? `HTTP status: ${response.status}` : `error: ${error}`;
127+
return `URL: "${url}", ${result}`;
128+
}
108129
}
109130

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

120-
/** @type {WeakMap<DatadogReporter, datadogApiClient.v1.MetricsApi>} */
121-
const datadogClients = new WeakMap();
141+
/** @type {WeakMap<DatadogReporter, string>} */
142+
const datadogApiKeys = new WeakMap();
122143

123144
/**
124145
* Create a reporter that sends metrics to Datadog's API.
@@ -142,43 +163,34 @@ class DatadogReporter {
142163
}
143164

144165
const apiKey = options.apiKey || process.env.DATADOG_API_KEY || process.env.DD_API_KEY;
145-
this.site = options.site
146-
|| process.env.DATADOG_SITE
147-
|| process.env.DD_SITE
148-
|| process.env.DATADOG_API_HOST;
149166

150167
if (!apiKey) {
151-
throw new Error(
168+
throw new DatadogMetricsError(
152169
'Datadog API key not found. You must specify one via the ' +
153170
'`apiKey` configuration option or the DATADOG_API_KEY or ' +
154171
'DD_API_KEY environment variable.'
155172
);
156173
}
157174

158-
const configuration = datadogApiClient.client.createConfiguration({
159-
authMethods: {
160-
apiKeyAuth: apiKey,
161-
},
162-
httpApi: new RetryHttp(),
175+
/** @private @type {HttpApi} */
176+
this.httpApi = new HttpApi({
163177
maxRetries: options.retries >= 0 ? options.retries : 2,
178+
retryBackoff: options.retryBackoff >= 0 ? options.retryBackoff : 1
164179
});
165180

166-
// HACK: Specify backoff here rather than in configration options to
167-
// support values less than 2 (mainly for faster tests).
168-
const backoff = options.retryBackoff >= 0 ? options.retryBackoff : 1;
169-
configuration.httpApi.backoffBase = backoff;
170-
171-
if (this.site) {
172-
// Strip leading `app.` from the site in case someone copy/pasted the
173-
// URL from their web browser. More details on correct configuration:
174-
// https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site
175-
this.site = this.site.replace(/^app\./i, '');
176-
configuration.setServerVariables({
177-
site: this.site
178-
});
179-
}
181+
/** @private @type {string} */
182+
this.site = options.site
183+
|| process.env.DATADOG_SITE
184+
|| process.env.DD_SITE
185+
|| process.env.DATADOG_API_HOST
186+
|| 'datadoghq.com';
180187

181-
datadogClients.set(this, new datadogApiClient.v1.MetricsApi(configuration));
188+
// Strip leading `app.` from the site in case someone copy/pasted the
189+
// URL from their web browser. More details on correct configuration:
190+
// https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site
191+
this.site = this.site.replace(/^app\./i, '');
192+
193+
datadogApiKeys.set(this, apiKey);
182194
}
183195

184196
/**
@@ -201,25 +213,19 @@ class DatadogReporter {
201213
}
202214
}
203215

204-
const metricsApi = datadogClients.get(this);
205-
206216
let submissions = [];
207217
if (metrics.length) {
208-
submissions.push(metricsApi.submitMetrics({
209-
body: { series: metrics }
210-
}));
218+
submissions.push(this.sendMetrics(metrics));
211219
}
212220
if (distributions.length) {
213-
submissions.push(metricsApi.submitDistributionPoints({
214-
body: { series: distributions }
215-
}));
221+
submissions.push(this.sendDistributions(distributions));
216222
}
217223

218224
try {
219225
await Promise.all(submissions);
220226
logDebug('sent metrics successfully');
221227
} catch (error) {
222-
if (error.code === 403) {
228+
if (error.status === 403) {
223229
throw new AuthorizationError(
224230
'Your Datadog API key is not authorized to send ' +
225231
'metrics. Check to make sure the DATADOG_API_KEY or ' +
@@ -235,6 +241,45 @@ class DatadogReporter {
235241
throw error;
236242
}
237243
}
244+
245+
/**
246+
* Send an array of metrics to the Datadog API.
247+
* @private
248+
* @param {any[]} series
249+
* @returns {Promise}
250+
*/
251+
sendMetrics(series) {
252+
return this.sendHttp('/v1/series', { body: { series } });
253+
}
254+
255+
/**
256+
* Send an array of distributions to the Datadog API.
257+
* @private
258+
* @param {any[]} series
259+
* @returns {Promise}
260+
*/
261+
sendDistributions(series) {
262+
return this.sendHttp('/v1/distribution_points', { body: { series } });
263+
}
264+
265+
/**
266+
* @private
267+
* @param {string} path
268+
* @param {any} options
269+
* @returns {Promise}
270+
*/
271+
async sendHttp(path, options) {
272+
const url = `https://api.${this.site}/api${path}`;
273+
const fetchOptions = {
274+
method: 'POST',
275+
headers: {
276+
'DD-API-KEY': datadogApiKeys.get(this),
277+
'Content-Type': 'application/json'
278+
},
279+
body: JSON.stringify(options.body)
280+
};
281+
return await this.httpApi.send(url, fetchOptions);
282+
}
238283
}
239284

240285
/**

0 commit comments

Comments
 (0)