From c27d406eee2f211e92910b24f3abc775481f0196 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:57:13 +0200 Subject: [PATCH] feat: fully functional New Relic sink --- .editorconfig | 4 +- package-lock.json | 82 +++++++++++++++++++++++++++++++++++++ package.json | 1 + src/index.ts | 3 +- src/sink.ts | 26 ------------ src/sinks/newrelic.ts | 61 +++++++++++++++++++++++++++ test/sinks/newrelic.spec.ts | 45 ++++++++++++++++++++ 7 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 src/sinks/newrelic.ts create mode 100644 test/sinks/newrelic.spec.ts diff --git a/.editorconfig b/.editorconfig index 1cbf162..411d78c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ root = true end_of_line = lf insert_final_newline = true -[*.js] +[*.js,*.ts] indent_size = 2 indent_style = space -quote_type = single \ No newline at end of file +quote_type = single diff --git a/package-lock.json b/package-lock.json index 8c2e6e5..a31fea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "eslint-plugin-security": "^1.5.0", "eslint-plugin-sonarjs": "^0.15.0", "jest": "^29.0.2", + "jest-fetch-mock": "^3.0.3", "markdown-toc": "^1.2.0", "ts-loader": "^9.3.1", "ts-node": "^10.9.1", @@ -3356,6 +3357,35 @@ "yarn": ">=1" } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, @@ -5943,6 +5973,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.4.3", "dev": true, @@ -7506,6 +7546,12 @@ "dev": true, "license": "MIT" }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "dev": true, @@ -11416,6 +11462,26 @@ "cross-spawn": "^7.0.1" } }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "requires": { + "node-fetch": "^2.6.12" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + } + } + }, "cross-spawn": { "version": "7.0.3", "dev": true, @@ -12991,6 +13057,16 @@ "jest-util": "^29.6.2" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "29.4.3", "dev": true @@ -14064,6 +14140,12 @@ "version": "2.0.1", "dev": true }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "prompts": { "version": "2.4.2", "dev": true, diff --git a/package.json b/package.json index 590dac3..3d1252b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eslint-plugin-security": "^1.5.0", "eslint-plugin-sonarjs": "^0.15.0", "jest": "^29.0.2", + "jest-fetch-mock": "^3.0.3", "markdown-toc": "^1.2.0", "ts-loader": "^9.3.1", "ts-node": "^10.9.1", diff --git a/src/index.ts b/src/index.ts index 726e335..97e7fb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './recorder'; export * from './metrics'; -export * from './sink'; \ No newline at end of file +export * from './sink'; +export * from './sinks/newrelic'; diff --git a/src/sink.ts b/src/sink.ts index 569b4dd..92a0a2d 100644 --- a/src/sink.ts +++ b/src/sink.ts @@ -1,6 +1,5 @@ import { Metrics } from './metrics'; -// Define the MetricSink interface export interface Sink { send(metrics: Metrics): Promise; } @@ -12,28 +11,3 @@ export class StdOutSink implements Sink { }); } } - -// Implement the MetricSink interface for the New Relic Metrics API -export class NewRelicSink implements Sink { - async send(metrics: Metrics): Promise { - // TODO convert metrics to New Relic format - const convertedMetrics = metrics; - - // Send the metric to the New Relic Metrics API - const response = await fetch('https://metric-api.newrelic.com/metric/v1', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-License-Key': 'YOUR_LICENSE_KEY_HERE' - }, - body: JSON.stringify({ - metrics: convertedMetrics - }) - }); - - // Check the response status and throw an error if it's not successful - if (!response.ok) { - throw new Error(`Failed to send metric to New Relic Metrics API: ${response.status} ${response.statusText}`); - } - } -} diff --git a/src/sinks/newrelic.ts b/src/sinks/newrelic.ts new file mode 100644 index 0000000..f371ca7 --- /dev/null +++ b/src/sinks/newrelic.ts @@ -0,0 +1,61 @@ +import { Sink } from '../sink'; +import { Metrics, MetricType } from '../metrics'; + +enum NRMetricType { + Count = 'count', + Distribution = 'distribution', + Gauge = 'gauge', + Summary = 'summary', + UniqueCount = 'uniqueCount', +} + +class NRMetric { + 'interval.ms': number; + constructor( + protected readonly name: string, + protected readonly type: NRMetricType, + protected readonly value: any = 1, + protected attributes = {}, + protected timestamp = Date.now(), + ) { + this['interval.ms'] = 1; + } +} + +export class NewRelicSink implements Sink { + constructor( + protected readonly licenseKey: string, + protected readonly apiEndpoint = 'https://metric-api.eu.newrelic.com/metric/v1' + ) {} + + async send(metrics: Metrics): Promise { + const nrMetrics = []; + for (const metric of metrics) { + switch (metric.type) { + case MetricType.Counter: + nrMetrics.push(new NRMetric(metric.name, NRMetricType.Count, metric.value, metric.metadata)); + break; + case MetricType.Gauge: + nrMetrics.push(new NRMetric(metric.name, NRMetricType.Gauge, metric.value, metric.metadata)); + break; + default: + console.log(`NewRelicSink does not have support for '${metric.type}' metrics yet`); + } + } + + // Send the metric to the New Relic Metrics API + const response = await fetch(this.apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-License-Key': this.licenseKey + }, + body: JSON.stringify([{ metrics: nrMetrics }]) + }); + + // Check the response status and throw an error if it's not successful + if (!response.ok) { + throw new Error(`Failed to send metrics to New Relic Metrics API: ${response.status} ${response.statusText}`); + } + } +} diff --git a/test/sinks/newrelic.spec.ts b/test/sinks/newrelic.spec.ts new file mode 100644 index 0000000..196bd96 --- /dev/null +++ b/test/sinks/newrelic.spec.ts @@ -0,0 +1,45 @@ +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; +import { NewRelicSink } from '../../src/sinks/newrelic'; +import { Metric, MetricType } from '../../src/metrics'; + +describe('Recorder', function() { + it('Sends metrics to New Relic - Response OK', async function() { + enableFetchMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-01-01')); // 1672531200000 + + fetchMock.mockResponseOnce(JSON.stringify({requestId: 'f0e7bfff-001a-b000-0000-01682bcf4565'}), {status: 202, headers: { 'Content-Type': 'application/json; charset=UTF-8' }}); + + const sink = new NewRelicSink('FAKE_LICENSE_KEY', 'https://newrelic.fake'); + const metrics = [ + new Metric('test_metric_one', MetricType.Counter, 1, { test: 'one' }), + new Metric('test_metric_two', MetricType.Counter, 5, { test: 'two' }), + new Metric('test_metric_three', MetricType.Gauge, 2876176, { test: 'three' }), + ]; + + const expectedFetchCall = { + body: JSON.stringify([{metrics: [ + { name: 'test_metric_one', type: 'count', value: 1, attributes: {test: 'one'}, timestamp: 1672531200000, 'interval.ms': 1 }, + { name: 'test_metric_two', type: 'count', value: 5, attributes: {test: 'two'}, timestamp: 1672531200000, 'interval.ms': 1 }, + { name: 'test_metric_three', type: 'gauge', value: 2876176, attributes: {test: 'three'}, timestamp: 1672531200000, 'interval.ms': 1 } + ]}]), + headers: { + 'Content-Type': 'application/json', + 'X-License-Key': 'FAKE_LICENSE_KEY' + }, + method: 'POST' + }; + + await sink.send(metrics); + expect(fetchMock.mock.calls.length).toEqual(1); + expect(fetchMock.mock.calls[0][0]).toEqual('https://newrelic.fake'); + expect(fetchMock.mock.calls[0][1]).toEqual(expectedFetchCall); + }); + + it('Sends metrics to New Relic - Response KO', async function() { + enableFetchMocks(); + fetchMock.mockResponseOnce('', { status: 400 }); + const sink = new NewRelicSink('', 'https://newrelic.fake'); + expect(async () => {await sink.send([]);}).rejects.toThrowError('Failed to send metrics to New Relic Metrics API: 400 Bad Request'); + }); +});