@unito/integration-sdk 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/src/handler.js +11 -0
  2. package/dist/src/index.cjs +114 -5
  3. package/dist/src/index.d.ts +2 -0
  4. package/dist/src/index.js +2 -0
  5. package/dist/src/integration.d.ts +1 -0
  6. package/dist/src/integration.js +2 -0
  7. package/dist/src/middlewares/finish.d.ts +0 -1
  8. package/dist/src/middlewares/finish.js +4 -1
  9. package/dist/src/middlewares/start.d.ts +2 -1
  10. package/dist/src/middlewares/start.js +2 -1
  11. package/dist/src/resources/context.d.ts +6 -0
  12. package/dist/src/resources/provider.d.ts +3 -0
  13. package/dist/src/resources/provider.js +15 -3
  14. package/dist/src/resources/requestMetrics.d.ts +24 -0
  15. package/dist/src/resources/requestMetrics.js +33 -0
  16. package/dist/src/resources/tracer.d.ts +16 -0
  17. package/dist/src/resources/tracer.js +45 -0
  18. package/dist/test/middlewares/finish.test.js +5 -4
  19. package/dist/test/middlewares/start.test.js +13 -2
  20. package/dist/test/resources/provider.test.js +95 -0
  21. package/dist/test/resources/requestMetrics.test.d.ts +1 -0
  22. package/dist/test/resources/requestMetrics.test.js +45 -0
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -1
  25. package/src/handler.ts +11 -0
  26. package/src/index.ts +2 -0
  27. package/src/integration.ts +3 -0
  28. package/src/middlewares/finish.ts +4 -2
  29. package/src/middlewares/start.ts +3 -2
  30. package/src/resources/context.ts +6 -0
  31. package/src/resources/provider.ts +20 -3
  32. package/src/resources/requestMetrics.ts +37 -0
  33. package/src/resources/tracer.ts +50 -0
  34. package/test/middlewares/finish.test.ts +13 -12
  35. package/test/middlewares/start.test.ts +13 -2
  36. package/test/resources/provider.test.ts +126 -0
  37. package/test/resources/requestMetrics.test.ts +50 -0
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { describe, it } from 'node:test';
3
3
  import onFinish from '../../src/middlewares/finish.js';
4
+ import { RequestMetrics } from '../../src/resources/requestMetrics.js';
4
5
  describe('finish middleware', () => {
5
6
  it('logs info', () => {
6
7
  let expected = '';
@@ -11,7 +12,7 @@ describe('finish middleware', () => {
11
12
  eventHandler = func;
12
13
  },
13
14
  locals: {
14
- requestStartTime: 0n,
15
+ requestMetrics: RequestMetrics.startRequest(),
15
16
  logger: {
16
17
  info: (_message) => {
17
18
  expected = 'works!';
@@ -33,7 +34,7 @@ describe('finish middleware', () => {
33
34
  eventHandler = func;
34
35
  },
35
36
  locals: {
36
- requestStartTime: 0n,
37
+ requestMetrics: RequestMetrics.startRequest(),
37
38
  logger: {
38
39
  error: (_message) => {
39
40
  expected = 'works!';
@@ -55,7 +56,7 @@ describe('finish middleware', () => {
55
56
  eventHandler = func;
56
57
  },
57
58
  locals: {
58
- requestStartTime: 0n,
59
+ requestMetrics: RequestMetrics.startRequest(),
59
60
  logger: {
60
61
  info: (_message) => {
61
62
  expected = 'ohoh!';
@@ -77,7 +78,7 @@ describe('finish middleware', () => {
77
78
  eventHandler = func;
78
79
  },
79
80
  locals: {
80
- requestStartTime: 0n,
81
+ requestMetrics: RequestMetrics.startRequest(),
81
82
  logger: {
82
83
  error: (_message) => {
83
84
  expected = 'works!';
@@ -1,11 +1,22 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { describe, it } from 'node:test';
3
3
  import start from '../../src/middlewares/start.js';
4
+ import { RequestMetrics } from '../../src/resources/requestMetrics.js';
4
5
  describe('start middleware', () => {
5
- it('adds request start time to locals', () => {
6
+ it('initializes request metrics on locals', () => {
6
7
  const request = {};
7
8
  const response = { locals: {} };
8
9
  start(request, response, () => { });
9
- assert.ok(response.locals.requestStartTime);
10
+ assert.ok(response.locals.requestMetrics instanceof RequestMetrics);
11
+ });
12
+ it('records request start time in metrics', () => {
13
+ const request = {};
14
+ const response = { locals: {} };
15
+ const before = process.hrtime.bigint();
16
+ start(request, response, () => { });
17
+ const result = response.locals.requestMetrics.endRequest();
18
+ const after = process.hrtime.bigint();
19
+ assert.ok(result.durationNs >= 0n);
20
+ assert.ok(result.durationNs <= after - before);
10
21
  });
11
22
  });
@@ -7,6 +7,7 @@ import https from 'https';
7
7
  import { Provider } from '../../src/resources/provider.js';
8
8
  import * as HttpErrors from '../../src/httpErrors.js';
9
9
  import Logger from '../../src/resources/logger.js';
10
+ import { RequestMetrics } from '../../src/resources/requestMetrics.js';
10
11
  // There is currently an issue with node 20.12 and fetch mocking. A quick fix is to first call fetch so it's getter
11
12
  // get properly instantiated, which allow it to be mocked properly.
12
13
  // Issue: https://github.com/nodejs/node/issues/52015
@@ -1336,4 +1337,98 @@ describe('Provider', () => {
1336
1337
  }
1337
1338
  });
1338
1339
  });
1340
+ describe('request metrics', () => {
1341
+ it('records api call duration in requestMetrics via fetchWrapper', async (context) => {
1342
+ const metrics = RequestMetrics.startRequest();
1343
+ const response = new Response('{"data": "value"}', {
1344
+ status: 200,
1345
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1346
+ });
1347
+ context.mock.method(global, 'fetch', () => Promise.resolve(response));
1348
+ await provider.get('/endpoint', {
1349
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1350
+ logger,
1351
+ requestMetrics: metrics,
1352
+ });
1353
+ const result = metrics.endRequest();
1354
+ assert.equal(result.apiCallCount, 1);
1355
+ assert.ok(result.totalApiDurationNs >= 0, 'Duration should be recorded');
1356
+ });
1357
+ it('records multiple api calls in the same requestMetrics', async (context) => {
1358
+ const metrics = RequestMetrics.startRequest();
1359
+ context.mock.method(global, 'fetch', () => Promise.resolve(new Response('{"data": "value"}', {
1360
+ status: 200,
1361
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1362
+ })));
1363
+ await provider.get('/endpoint1', {
1364
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1365
+ logger,
1366
+ requestMetrics: metrics,
1367
+ });
1368
+ await provider.post('/endpoint2', { key: 'value' }, {
1369
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1370
+ logger,
1371
+ requestMetrics: metrics,
1372
+ });
1373
+ const result = metrics.endRequest();
1374
+ assert.equal(result.apiCallCount, 2);
1375
+ assert.ok(result.totalApiDurationNs >= 0);
1376
+ });
1377
+ it('works without requestMetrics (backward compatible)', async (context) => {
1378
+ const response = new Response('{"data": "value"}', {
1379
+ status: 200,
1380
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1381
+ });
1382
+ context.mock.method(global, 'fetch', () => Promise.resolve(response));
1383
+ // No requestMetrics passed — should not throw
1384
+ const result = await provider.get('/endpoint', {
1385
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1386
+ logger,
1387
+ });
1388
+ assert.equal(result.status, 200);
1389
+ });
1390
+ it('records api call duration from postForm', async () => {
1391
+ const metrics = RequestMetrics.startRequest();
1392
+ const httpsProvider = new Provider({
1393
+ prepareRequest: () => ({
1394
+ url: 'https://www.myApi.com',
1395
+ headers: {},
1396
+ }),
1397
+ });
1398
+ const scope = nock('https://www.myApi.com').post('/upload').reply(201, { success: true });
1399
+ const FormData = (await import('form-data')).default;
1400
+ const form = new FormData();
1401
+ form.append('file', Buffer.from('test data'), 'test.txt');
1402
+ await httpsProvider.postForm('/upload', form, {
1403
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1404
+ logger,
1405
+ requestMetrics: metrics,
1406
+ });
1407
+ const postFormResult = metrics.endRequest();
1408
+ assert.equal(postFormResult.apiCallCount, 1);
1409
+ assert.ok(postFormResult.totalApiDurationNs > 0);
1410
+ scope.isDone();
1411
+ });
1412
+ it('records api call duration from postStream', async () => {
1413
+ const metrics = RequestMetrics.startRequest();
1414
+ const testData = 'binary stream data';
1415
+ const httpsProvider = new Provider({
1416
+ prepareRequest: () => ({
1417
+ url: 'https://www.myApi.com',
1418
+ headers: {},
1419
+ }),
1420
+ });
1421
+ const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });
1422
+ const stream = Readable.from(Buffer.from(testData));
1423
+ await httpsProvider.postStream('/upload', stream, {
1424
+ credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
1425
+ logger,
1426
+ requestMetrics: metrics,
1427
+ });
1428
+ const postStreamResult = metrics.endRequest();
1429
+ assert.equal(postStreamResult.apiCallCount, 1);
1430
+ assert.ok(postStreamResult.totalApiDurationNs > 0);
1431
+ scope.isDone();
1432
+ });
1433
+ });
1339
1434
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { RequestMetrics } from '../../src/resources/requestMetrics.js';
4
+ describe('RequestMetrics', () => {
5
+ describe('initial state', () => {
6
+ it('starts with zero api call count', () => {
7
+ const metrics = RequestMetrics.startRequest();
8
+ const result = metrics.endRequest();
9
+ assert.equal(result.apiCallCount, 0);
10
+ });
11
+ it('starts with zero total api duration', () => {
12
+ const metrics = RequestMetrics.startRequest();
13
+ const result = metrics.endRequest();
14
+ assert.equal(result.totalApiDurationNs, 0);
15
+ });
16
+ });
17
+ describe('endRequest', () => {
18
+ it('returns elapsed duration since construction', () => {
19
+ const before = process.hrtime.bigint();
20
+ const metrics = RequestMetrics.startRequest();
21
+ const result = metrics.endRequest();
22
+ const after = process.hrtime.bigint();
23
+ assert.ok(result.durationNs >= 0n);
24
+ assert.ok(result.durationNs <= after - before);
25
+ });
26
+ });
27
+ describe('recordExternalApiCall', () => {
28
+ it('increments api call count', () => {
29
+ const metrics = RequestMetrics.startRequest();
30
+ const start1 = process.hrtime.bigint();
31
+ metrics.recordExternalApiCall(start1);
32
+ const start2 = process.hrtime.bigint();
33
+ metrics.recordExternalApiCall(start2);
34
+ const result = metrics.endRequest();
35
+ assert.equal(result.apiCallCount, 2);
36
+ });
37
+ it('accumulates api duration', () => {
38
+ const metrics = RequestMetrics.startRequest();
39
+ const start = process.hrtime.bigint();
40
+ metrics.recordExternalApiCall(start);
41
+ const result = metrics.endRequest();
42
+ assert.ok(result.totalApiDurationNs >= 0);
43
+ });
44
+ });
45
+ });
@@ -1 +1 @@
1
- {"root":["../src/errors.ts","../src/handler.ts","../src/helpers.ts","../src/httpErrors.ts","../src/index.ts","../src/integration.ts","../src/middlewares/correlationId.ts","../src/middlewares/credentials.ts","../src/middlewares/errors.ts","../src/middlewares/filters.ts","../src/middlewares/finish.ts","../src/middlewares/health.ts","../src/middlewares/logger.ts","../src/middlewares/notFound.ts","../src/middlewares/relations.ts","../src/middlewares/search.ts","../src/middlewares/secrets.ts","../src/middlewares/selects.ts","../src/middlewares/signal.ts","../src/middlewares/start.ts","../src/resources/cache.ts","../src/resources/context.ts","../src/resources/logger.ts","../src/resources/provider.ts","../test/errors.test.ts","../test/handler.test.ts","../test/helpers.test.ts","../test/integration.test.ts","../test/middlewares/correlationId.test.ts","../test/middlewares/credentials.test.ts","../test/middlewares/errors.test.ts","../test/middlewares/filters.test.ts","../test/middlewares/finish.test.ts","../test/middlewares/health.test.ts","../test/middlewares/logger.test.ts","../test/middlewares/notFound.test.ts","../test/middlewares/relations.test.ts","../test/middlewares/search.test.ts","../test/middlewares/secrets.test.ts","../test/middlewares/selects.test.ts","../test/middlewares/signal.test.ts","../test/middlewares/start.test.ts","../test/resources/cache.test.ts","../test/resources/logger.test.ts","../test/resources/provider.test.ts"],"version":"5.9.3"}
1
+ {"root":["../src/errors.ts","../src/handler.ts","../src/helpers.ts","../src/httpErrors.ts","../src/index.ts","../src/integration.ts","../src/middlewares/correlationId.ts","../src/middlewares/credentials.ts","../src/middlewares/errors.ts","../src/middlewares/filters.ts","../src/middlewares/finish.ts","../src/middlewares/health.ts","../src/middlewares/logger.ts","../src/middlewares/notFound.ts","../src/middlewares/relations.ts","../src/middlewares/search.ts","../src/middlewares/secrets.ts","../src/middlewares/selects.ts","../src/middlewares/signal.ts","../src/middlewares/start.ts","../src/resources/cache.ts","../src/resources/context.ts","../src/resources/logger.ts","../src/resources/provider.ts","../src/resources/requestMetrics.ts","../src/resources/tracer.ts","../test/errors.test.ts","../test/handler.test.ts","../test/helpers.test.ts","../test/integration.test.ts","../test/middlewares/correlationId.test.ts","../test/middlewares/credentials.test.ts","../test/middlewares/errors.test.ts","../test/middlewares/filters.test.ts","../test/middlewares/finish.test.ts","../test/middlewares/health.test.ts","../test/middlewares/logger.test.ts","../test/middlewares/notFound.test.ts","../test/middlewares/relations.test.ts","../test/middlewares/search.test.ts","../test/middlewares/secrets.test.ts","../test/middlewares/selects.test.ts","../test/middlewares/signal.test.ts","../test/middlewares/start.test.ts","../test/resources/cache.test.ts","../test/resources/logger.test.ts","../test/resources/provider.test.ts","../test/resources/requestMetrics.test.ts"],"version":"5.9.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
@@ -56,6 +56,7 @@
56
56
  "@unito/integration-api": "7.x",
57
57
  "busboy": "^1.6.0",
58
58
  "cachette": "4.x",
59
+ "dd-trace": "5.x",
59
60
  "express": "^5.1",
60
61
  "form-data": "^4.0.0"
61
62
  },
package/src/handler.ts CHANGED
@@ -308,6 +308,7 @@ export class Handler {
308
308
  filters: res.locals.filters,
309
309
  logger: res.locals.logger,
310
310
  signal: res.locals.signal,
311
+ requestMetrics: res.locals.requestMetrics,
311
312
  params: req.params,
312
313
  query: req.query,
313
314
  relations: res.locals.relations,
@@ -335,6 +336,7 @@ export class Handler {
335
336
  body: req.body,
336
337
  logger: res.locals.logger,
337
338
  signal: res.locals.signal,
339
+ requestMetrics: res.locals.requestMetrics,
338
340
  params: req.params,
339
341
  query: req.query,
340
342
  });
@@ -383,6 +385,7 @@ export class Handler {
383
385
  body,
384
386
  logger: res.locals.logger,
385
387
  signal: res.locals.signal,
388
+ requestMetrics: res.locals.requestMetrics,
386
389
  params: req.params,
387
390
  query: req.query,
388
391
  });
@@ -417,6 +420,7 @@ export class Handler {
417
420
  secrets: res.locals.secrets,
418
421
  logger: res.locals.logger,
419
422
  signal: res.locals.signal,
423
+ requestMetrics: res.locals.requestMetrics,
420
424
  params: req.params,
421
425
  query: req.query,
422
426
  });
@@ -443,6 +447,7 @@ export class Handler {
443
447
  body: req.body,
444
448
  logger: res.locals.logger,
445
449
  signal: res.locals.signal,
450
+ requestMetrics: res.locals.requestMetrics,
446
451
  params: req.params,
447
452
  query: req.query,
448
453
  });
@@ -466,6 +471,7 @@ export class Handler {
466
471
  secrets: res.locals.secrets,
467
472
  logger: res.locals.logger,
468
473
  signal: res.locals.signal,
474
+ requestMetrics: res.locals.requestMetrics,
469
475
  params: req.params,
470
476
  query: req.query,
471
477
  });
@@ -489,6 +495,7 @@ export class Handler {
489
495
  secrets: res.locals.secrets,
490
496
  logger: res.locals.logger,
491
497
  signal: res.locals.signal,
498
+ requestMetrics: res.locals.requestMetrics,
492
499
  params: req.params,
493
500
  query: req.query,
494
501
  });
@@ -533,6 +540,7 @@ export class Handler {
533
540
  secrets: res.locals.secrets,
534
541
  logger: res.locals.logger,
535
542
  signal: res.locals.signal,
543
+ requestMetrics: res.locals.requestMetrics,
536
544
  params: req.params,
537
545
  query: req.query,
538
546
  });
@@ -553,6 +561,7 @@ export class Handler {
553
561
  secrets: res.locals.secrets,
554
562
  logger: res.locals.logger,
555
563
  signal: res.locals.signal,
564
+ requestMetrics: res.locals.requestMetrics,
556
565
  params: req.params,
557
566
  query: req.query,
558
567
  body: req.body,
@@ -574,6 +583,7 @@ export class Handler {
574
583
  secrets: res.locals.secrets,
575
584
  logger: res.locals.logger,
576
585
  signal: res.locals.signal,
586
+ requestMetrics: res.locals.requestMetrics,
577
587
  params: req.params,
578
588
  query: req.query,
579
589
  body: req.body,
@@ -601,6 +611,7 @@ export class Handler {
601
611
  body: req.body,
602
612
  logger: res.locals.logger,
603
613
  signal: res.locals.signal,
614
+ requestMetrics: res.locals.requestMetrics,
604
615
  params: req.params,
605
616
  query: req.query,
606
617
  });
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  type RequestOptions as ProviderRequestOptions,
11
11
  type RateLimiter,
12
12
  } from './resources/provider.js';
13
+ export { RequestMetrics } from './resources/requestMetrics.js';
13
14
  export type { Secrets } from './middlewares/secrets.js';
14
15
  export type { Credentials } from './middlewares/credentials.js';
15
16
  export type { Filter } from './middlewares/filters.js';
@@ -17,4 +18,5 @@ export * as HttpErrors from './httpErrors.js';
17
18
  export { buildCollectionQueryParams, getApplicableFilters } from './helpers.js';
18
19
  export * from './resources/context.js';
19
20
  export { type default as Logger, NULL_LOGGER } from './resources/logger.js';
21
+ export { DD_TRACER } from './resources/tracer.js';
20
22
  /* c8 ignore stop */
@@ -1,3 +1,6 @@
1
+ // Must be the very first import so dd-trace instruments all subsequent modules.
2
+ import './resources/tracer.js';
3
+
1
4
  import { Server, IncomingMessage, ServerResponse } from 'http';
2
5
  import express from 'express';
3
6
 
@@ -8,7 +8,6 @@ declare global {
8
8
  interface Locals {
9
9
  logger: Logger;
10
10
  error?: ApiError;
11
- requestStartTime: bigint;
12
11
  }
13
12
  }
14
13
  }
@@ -18,12 +17,15 @@ function onFinish(req: Request, res: Response, next: NextFunction) {
18
17
  const logger = res.locals.logger ?? new Logger();
19
18
  const error = res.locals.error;
20
19
 
21
- const durationInNs = Number(process.hrtime.bigint() - res.locals.requestStartTime);
20
+ const endMetrics = res.locals.requestMetrics.endRequest();
21
+ const durationInNs = Number(endMetrics.durationNs);
22
22
  const durationInMs = (durationInNs / 1_000_000) | 0;
23
23
 
24
24
  const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${durationInMs} ms`;
25
25
  const metadata: Metadata = {
26
26
  duration: durationInNs,
27
+ externalApiCount: endMetrics.apiCallCount,
28
+ externalApiTotalDuration: endMetrics.totalApiDurationNs,
27
29
  // Use reserved and standard attributes of Datadog
28
30
  // https://app.datadoghq.com/logs/pipelines/standard-attributes
29
31
  http: { method: req.method, status_code: res.statusCode, url_details: { path: req.originalUrl } },
@@ -1,16 +1,17 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
+ import { RequestMetrics } from '../resources/requestMetrics.js';
2
3
 
3
4
  declare global {
4
5
  // eslint-disable-next-line @typescript-eslint/no-namespace
5
6
  namespace Express {
6
7
  interface Locals {
7
- requestStartTime: bigint;
8
+ requestMetrics: RequestMetrics;
8
9
  }
9
10
  }
10
11
  }
11
12
 
12
13
  function start(_: Request, res: Response, next: NextFunction) {
13
- res.locals.requestStartTime = process.hrtime.bigint();
14
+ res.locals.requestMetrics = RequestMetrics.startRequest();
14
15
 
15
16
  next();
16
17
  }
@@ -1,6 +1,7 @@
1
1
  /* c8 ignore start */
2
2
  import * as API from '@unito/integration-api';
3
3
  import Logger from './logger.js';
4
+ import { RequestMetrics } from './requestMetrics.js';
4
5
  import { Credentials } from '../middlewares/credentials.js';
5
6
  import { Secrets } from '../middlewares/secrets.js';
6
7
  import { Filter } from '../middlewares/filters.js';
@@ -43,6 +44,11 @@ export type Context<P extends Maybe<Params> = Maybe<Params>, Q extends Maybe<Que
43
44
  * to be timed out. You can use this signal to abort any operation that would exceed this time frame.
44
45
  */
45
46
  signal: AbortSignal | undefined;
47
+ /**
48
+ * Per-request metrics accumulator for Provider API calls.
49
+ * Automatically populated by the SDK middleware and used by Provider to track call count and duration.
50
+ */
51
+ requestMetrics: RequestMetrics | undefined;
46
52
  /**
47
53
  * The request params.
48
54
  *
@@ -7,6 +7,7 @@ import { buildHttpError } from '../errors.js';
7
7
  import * as HttpErrors from '../httpErrors.js';
8
8
  import { Credentials } from '../middlewares/credentials.js';
9
9
  import Logger from '../resources/logger.js';
10
+ import { RequestMetrics } from './requestMetrics.js';
10
11
 
11
12
  /**
12
13
  * RateLimiter is a wrapper function that you can provide to limit the rate of calls to the provider based on the
@@ -44,6 +45,7 @@ export type RequestOptions = {
44
45
  queryParams?: { [key: string]: string };
45
46
  additionnalheaders?: { [key: string]: string };
46
47
  rawBody?: boolean;
48
+ requestMetrics?: RequestMetrics;
47
49
  };
48
50
 
49
51
  /**
@@ -278,7 +280,7 @@ export class Provider {
278
280
  });
279
281
  };
280
282
 
281
- return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
283
+ return this.timedCallToProvider(callToProvider, options);
282
284
  }
283
285
 
284
286
  /**
@@ -406,7 +408,7 @@ export class Provider {
406
408
  });
407
409
  };
408
410
 
409
- return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
411
+ return this.timedCallToProvider(callToProvider, options);
410
412
  }
411
413
 
412
414
  /**
@@ -687,7 +689,7 @@ export class Provider {
687
689
  return { status: response.status, headers: Object.fromEntries(response.headers.entries()), body };
688
690
  };
689
691
 
690
- return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
692
+ return this.timedCallToProvider(callToProvider, options);
691
693
  }
692
694
 
693
695
  /**
@@ -702,6 +704,21 @@ export class Provider {
702
704
  );
703
705
  }
704
706
 
707
+ private async timedCallToProvider<T>(
708
+ callToProvider: () => Promise<Response<T>>,
709
+ options: RequestOptions,
710
+ ): Promise<Response<T>> {
711
+ const timedCall = async (): Promise<Response<T>> => {
712
+ const start = process.hrtime.bigint();
713
+ try {
714
+ return await callToProvider();
715
+ } finally {
716
+ options.requestMetrics?.recordExternalApiCall(start);
717
+ }
718
+ };
719
+ return this.rateLimiter ? this.rateLimiter(options, timedCall) : timedCall();
720
+ }
721
+
705
722
  private handleError(responseStatus: number, message: string, options: RequestOptions): HttpErrors.HttpError {
706
723
  const customError = this.customErrorHandler?.(responseStatus, message, options);
707
724
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Accumulates per-request metrics for Provider API calls.
3
+ *
4
+ * Created once per incoming request (in the start middleware), threaded through Context and RequestOptions,
5
+ * incremented by Provider on each API call, and logged by the finish middleware.
6
+ */
7
+ export class RequestMetrics {
8
+ private _apiCallCount = 0;
9
+ private _totalApiDurationNs = 0;
10
+ private _requestStartTime: bigint;
11
+
12
+ private constructor() {
13
+ this._requestStartTime = process.hrtime.bigint();
14
+ }
15
+
16
+ static startRequest(): RequestMetrics {
17
+ return new RequestMetrics();
18
+ }
19
+
20
+ endRequest(): { durationNs: bigint; apiCallCount: number; totalApiDurationNs: number } {
21
+ return {
22
+ durationNs: process.hrtime.bigint() - this._requestStartTime,
23
+ apiCallCount: this._apiCallCount,
24
+ totalApiDurationNs: this._totalApiDurationNs,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Record a completed API call.
30
+ * Computes the duration from the given start timestamp, increments the call counter,
31
+ * and accumulates the duration.
32
+ */
33
+ recordExternalApiCall(startTime: bigint): void {
34
+ this._apiCallCount++;
35
+ this._totalApiDurationNs += Number(process.hrtime.bigint() - startTime);
36
+ }
37
+ }
@@ -0,0 +1,50 @@
1
+ import tracer, { TracerOptions } from 'dd-trace';
2
+
3
+ if (process.env.NODE_ENV === 'production' && process.env.DD_TRACE_ENABLED === 'true') {
4
+ // List of options available to the tracer: https://datadoghq.dev/dd-trace-js/interfaces/export_.TracerOptions.html
5
+ const apmConfig: TracerOptions = { logInjection: false, profiling: false, sampleRate: 0 };
6
+
7
+ if (process.env.DD_APM_ENABLED == 'true') {
8
+ apmConfig.logInjection = true;
9
+ apmConfig.sampleRate = 1;
10
+ } else {
11
+ // The "enable tracing" boolean is not exposed as part of the TracerOptions so we
12
+ // have to do so through env vars. Setting here instead of in services' config
13
+ // to avoid too many env vars to "flip" when enabling / disabling APM.
14
+ process.env.DD_TRACING_ENABLED = 'false';
15
+ }
16
+
17
+ // Profiling is extra $$$ so we're setting it as an "extra" when needed
18
+ if (process.env.DD_APM_PROFILING_ENABLED == 'true') {
19
+ apmConfig.profiling = true;
20
+ }
21
+
22
+ const tags: { [key: string]: any } = {};
23
+ // Using DD_TRACE_AGENT_URL as a hack to know if we're running inside of Kubernetes
24
+ // since this variable is not needed in Elastic Beanstalk of Lambda
25
+ if (process.env.DD_TRACE_AGENT_URL) {
26
+ tags.pod_name = process.env.HOSTNAME;
27
+ }
28
+
29
+ tracer.init({
30
+ ...apmConfig, // Conditionally enable APM tracing & profiling
31
+ runtimeMetrics: true, // Always enable runtime metrics https://docs.datadoghq.com/tracing/metrics/runtime_metrics/nodejs/?tab=environmentvariables
32
+ tags,
33
+ }); // initialized in a different file to avoid hoisting.
34
+ }
35
+
36
+ /**
37
+ * WARNING to projects importing this
38
+ * Even if dd-tracer documents that instrumentation happens at **.init() time**
39
+ * (see https://docs.datadoghq.com/tracing/trace_collection/automatic_instrumentation/dd_libraries/nodejs/#import-and-initialize-the-tracer ),
40
+ * 1. dd-tracer has no choice but to do some stuff **at import time**
41
+ * 2. dd-tracer is not exempt from bugs, like https://github.com/DataDog/dd-trace-js/issues/5211
42
+ *
43
+ * So, do not assume that dd-trace is *fully off* by default! Some of it runs!
44
+ * If you want to entirely disable all of dd-trace (e.g. during unit tests),
45
+ * then you must set `DD_TRACE_ENABLED=false`
46
+ *
47
+ * Q: Why not move the dd-trace import inside the `if DD_TRACE_ENABLED` condition, then?
48
+ * A: Because the future is ESM, and ESM imports must be static and top-level.
49
+ */
50
+ export const DD_TRACER = tracer;