diff --git a/frontend/src/assets/icons/new-relic.svg b/frontend/src/assets/icons/new-relic.svg new file mode 100644 index 000000000000..300b9bf79424 --- /dev/null +++ b/frontend/src/assets/icons/new-relic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/component/integrations/IntegrationList/IntegrationIcon/IntegrationIcon.tsx b/frontend/src/component/integrations/IntegrationList/IntegrationIcon/IntegrationIcon.tsx index abbc805384c0..beade3a8d8c6 100644 --- a/frontend/src/component/integrations/IntegrationList/IntegrationIcon/IntegrationIcon.tsx +++ b/frontend/src/component/integrations/IntegrationList/IntegrationIcon/IntegrationIcon.tsx @@ -5,6 +5,7 @@ import { formatAssetPath } from 'utils/formatPath'; import { capitalizeFirst } from 'utils/capitalizeFirst'; import dataDogIcon from 'assets/icons/datadog.svg'; +import newRelicIcon from 'assets/icons/new-relic.svg'; import jiraIcon from 'assets/icons/jira.svg'; import jiraCommentIcon from 'assets/icons/jira-comment.svg'; import signals from 'assets/icons/signals.svg'; @@ -50,6 +51,7 @@ const integrations: Record< } > = { datadog: { title: 'Datadog', icon: dataDogIcon }, + 'new-relic': { title: 'New Relic', icon: newRelicIcon }, jira: { title: 'Jira', icon: jiraIcon }, 'jira-comment': { title: 'Jira', icon: jiraCommentIcon }, signals: { title: 'Signals', icon: signals }, diff --git a/src/lib/addons/__snapshots__/new-relic.test.ts.snap b/src/lib/addons/__snapshots__/new-relic.test.ts.snap new file mode 100644 index 000000000000..e37c7d99e145 --- /dev/null +++ b/src/lib/addons/__snapshots__/new-relic.test.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should call New Relic Event API for $type toggle 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", +} +`; + +exports[`Should call New Relic Event API for FEATURE_ARCHIVED toggle with project info 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", +} +`; + +exports[`Should call New Relic Event API for FEATURE_ARCHIVED with project info 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", +} +`; + +exports[`Should call New Relic Event API for custom body template 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", +} +`; + +exports[`Should call New Relic Event API for customHeaders in headers when calling service 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", + "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE", +} +`; + +exports[`Should call New Relic Event API for toggled environment 1`] = ` +{ + "Api-Key": "fakeLicenseKey", + "Content-Encoding": "gzip", + "Content-Type": "application/json", +} +`; diff --git a/src/lib/addons/index.ts b/src/lib/addons/index.ts index d91c9e635617..61e8725c9f68 100644 --- a/src/lib/addons/index.ts +++ b/src/lib/addons/index.ts @@ -2,6 +2,7 @@ import Webhook from './webhook'; import SlackAddon from './slack'; import TeamsAddon from './teams'; import DatadogAddon from './datadog'; +import NewRelicAddon from './new-relic'; import type Addon from './addon'; import type { LogProvider } from '../logger'; import SlackAppAddon from './slack-app'; @@ -22,6 +23,7 @@ export const getAddons: (args: { new SlackAppAddon({ getLogger, unleashUrl }), new TeamsAddon({ getLogger, unleashUrl }), new DatadogAddon({ getLogger, unleashUrl }), + new NewRelicAddon({ getLogger, unleashUrl }), ]; return addons.reduce((map, addon) => { diff --git a/src/lib/addons/new-relic-definition.ts b/src/lib/addons/new-relic-definition.ts new file mode 100644 index 000000000000..e34079940bc1 --- /dev/null +++ b/src/lib/addons/new-relic-definition.ts @@ -0,0 +1,102 @@ +import { + FEATURE_ARCHIVED, + FEATURE_CREATED, + FEATURE_ENVIRONMENT_DISABLED, + FEATURE_ENVIRONMENT_ENABLED, + FEATURE_ENVIRONMENT_VARIANTS_UPDATED, + FEATURE_METADATA_UPDATED, + FEATURE_POTENTIALLY_STALE_ON, + FEATURE_PROJECT_CHANGE, + FEATURE_REVIVED, + FEATURE_STALE_OFF, + FEATURE_STALE_ON, + FEATURE_STRATEGY_ADD, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, + FEATURE_UPDATED, +} from '../types/events'; +import type { IAddonDefinition } from '../types/model'; + +const newRelicDefinition: IAddonDefinition = { + name: 'new-relic', + displayName: 'New Relic', + description: 'Allows Unleash to post updates to New Relic Event API.', + documentationUrl: 'https://docs.getunleash.io/docs/addons/new-relic', + howTo: 'The New Relic integration allows Unleash to post Updates to New Relic Event API when a feature flag is updated.', + parameters: [ + { + name: 'url', + displayName: 'New Relic Event URL', + description: + '(Required) If data is hosted in EU then use the EU region endpoints (https://docs.newrelic.com/docs/using-new-relic/welcome-new-relic/getting-started/introduction-eu-region-data-center/#endpoints). Otherwise, it should be something like this: https://insights-collector.newrelic.com/v1/accounts/YOUR_ACCOUNT_ID/events', + type: 'url', + required: true, + sensitive: false, + }, + { + name: 'licenseKey', + displayName: 'New Relic License Key', + placeholder: 'eu01xx0f12a6b3434a8d710110bd862', + description: '(Required) License key to connect to New Relic', + type: 'text', + required: true, + sensitive: true, + }, + { + name: 'customHeaders', + displayName: 'Extra HTTP Headers', + placeholder: `{ + "SOME_CUSTOM_HTTP_HEADER": "SOME_VALUE", + "SOME_OTHER_CUSTOM_HTTP_HEADER": "SOME_OTHER_VALUE" +}`, + description: + '(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings', + required: false, + sensitive: true, + type: 'textfield', + }, + { + name: 'bodyTemplate', + displayName: 'Body template', + placeholder: `{ + "event": "{{event.type}}", + "eventType": "unleash", + "createdBy": "{{event.createdBy}}", + "featureToggle": "{{event.data.name}}", + "timestamp": "{{event.data.createdAt}}" +}`, + description: + '(Optional) The default format is a markdown string formatted by Unleash. You may override the format of the body using a mustache template.', + required: false, + sensitive: false, + type: 'textfield', + }, + ], + events: [ + FEATURE_CREATED, + FEATURE_UPDATED, + FEATURE_ARCHIVED, + FEATURE_REVIVED, + FEATURE_STALE_ON, + FEATURE_STALE_OFF, + FEATURE_ENVIRONMENT_ENABLED, + FEATURE_ENVIRONMENT_DISABLED, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, + FEATURE_STRATEGY_ADD, + FEATURE_METADATA_UPDATED, + FEATURE_PROJECT_CHANGE, + FEATURE_ENVIRONMENT_VARIANTS_UPDATED, + FEATURE_POTENTIALLY_STALE_ON, + ], + tagTypes: [ + { + name: 'new-relic', + description: + 'All New Relic tags added to a specific feature are sent to New Relic Event API.', + icon: 'D', + }, + ], +}; + +export default newRelicDefinition; diff --git a/src/lib/addons/new-relic.test.ts b/src/lib/addons/new-relic.test.ts new file mode 100644 index 000000000000..e3870211c930 --- /dev/null +++ b/src/lib/addons/new-relic.test.ts @@ -0,0 +1,164 @@ +import { + FEATURE_ARCHIVED, + FEATURE_CREATED, + FEATURE_ENVIRONMENT_DISABLED, + type IEvent, +} from '../types'; +import type { Logger } from '../logger'; + +import NewRelicAddon, { type INewRelicParameters } from './new-relic'; + +import noLogger from '../../test/fixtures/no-logger'; +import { gunzip } from 'node:zlib'; +import { promisify } from 'util'; + +const asyncGunzip = promisify(gunzip); + +let fetchRetryCalls: any[] = []; + +jest.mock( + './addon', + () => + class Addon { + logger: Logger; + + constructor(definition, { getLogger }) { + this.logger = getLogger('addon/test'); + fetchRetryCalls = []; + } + + async fetchRetry(url, options, retries, backoff) { + fetchRetryCalls.push({ + url, + options, + retries, + backoff, + }); + return Promise.resolve({ status: 200 }); + } + }, +); + +const defaultParameters = { + url: 'fakeUrl', + licenseKey: 'fakeLicenseKey', +} as INewRelicParameters; + +const defaultEvent = { + id: 1, + createdAt: new Date(), + type: FEATURE_CREATED, + createdBy: 'some@user.com', + createdByUserId: -1337, + featureName: 'some-toggle', + data: { + name: 'some-toggle', + enabled: false, + strategies: [{ name: 'default' }], + }, +} as IEvent; + +const makeAddHandleEvent = (event: IEvent, parameters: INewRelicParameters) => { + const addon = new NewRelicAddon({ + getLogger: noLogger, + unleashUrl: 'http://some-url.com', + }); + + return () => addon.handleEvent(event, parameters); +}; + +test.each([ + { + partialEvent: { type: FEATURE_CREATED }, + test: '$type toggle', + }, + { + partialEvent: { + type: FEATURE_ARCHIVED, + data: { + name: 'some-toggle', + }, + }, + test: 'FEATURE_ARCHIVED toggle with project info', + }, + { + partialEvent: { + type: FEATURE_ARCHIVED, + project: 'some-project', + data: { + name: 'some-toggle', + }, + }, + test: 'FEATURE_ARCHIVED with project info', + }, + { + partialEvent: { + type: FEATURE_ENVIRONMENT_DISABLED, + environment: 'development', + }, + test: 'toggled environment', + }, + { + partialEvent: { + type: FEATURE_ENVIRONMENT_DISABLED, + environment: 'development', + }, + partialParameters: { + customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`, + }, + test: 'customHeaders in headers when calling service', + }, + { + partialEvent: { + type: FEATURE_ENVIRONMENT_DISABLED, + environment: 'development', + }, + partialParameters: { + bodyTemplate: + '{\n "eventType": "{{event.type}}",\n "createdBy": "{{event.createdBy}}"\n}', + }, + test: 'custom body template', + }, +] as Array<{ + partialEvent: Partial; + partialParameters?: Partial; + test: String; +}>)( + 'Should call New Relic Event API for $test', + async ({ partialEvent, partialParameters }) => { + const event = { + ...defaultEvent, + ...partialEvent, + }; + + const parameters = { + ...defaultParameters, + ...partialParameters, + }; + + const handleEvent = makeAddHandleEvent(event, parameters); + + await handleEvent(); + expect(fetchRetryCalls.length).toBe(1); + + const { url, options } = fetchRetryCalls[0]; + const jsonBody = JSON.parse( + (await asyncGunzip(options.body)).toString(), + ); + + expect(url).toBe(parameters.url); + expect(options.method).toBe('POST'); + expect(options.headers['Api-Key']).toBe(parameters.licenseKey); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(options.headers['Content-Encoding']).toBe('gzip'); + expect(options.headers).toMatchSnapshot(); + + expect(jsonBody.eventType).toBe('UnleashServiceEvent'); + expect(jsonBody.unleashEventType).toBe(event.type); + expect(jsonBody.featureName).toBe(event.data.name); + expect(jsonBody.environment).toBe(event.environment); + expect(jsonBody.createdBy).toBe(event.createdBy); + expect(jsonBody.createdByUserId).toBe(event.createdByUserId); + expect(jsonBody.createdAt).toBe(event.createdAt.getTime()); + }, +); diff --git a/src/lib/addons/new-relic.ts b/src/lib/addons/new-relic.ts new file mode 100644 index 000000000000..028a516a95e8 --- /dev/null +++ b/src/lib/addons/new-relic.ts @@ -0,0 +1,98 @@ +import Addon from './addon'; + +import definition from './new-relic-definition'; +import Mustache from 'mustache'; +import type { IAddonConfig, IEvent, IEventType } from '../types'; +import { + type FeatureEventFormatter, + FeatureEventFormatterMd, + LinkStyle, +} from './feature-event-formatter-md'; +import { gzip } from 'node:zlib'; +import { promisify } from 'util'; + +const asyncGzip = promisify(gzip); + +export interface INewRelicParameters { + url: string; + licenseKey: string; + customHeaders?: string; + bodyTemplate?: string; +} + +interface INewRelicRequestBody { + eventType: 'Unleash Service Event'; + unleashEventType: IEventType; + featureName: IEvent['featureName']; + environment: IEvent['environment']; + createdBy: IEvent['createdBy']; + createdByUserId: IEvent['createdByUserId']; + createdAt: IEvent['createdAt']; +} + +export default class NewRelicAddon extends Addon { + private msgFormatter: FeatureEventFormatter; + + constructor(config: IAddonConfig) { + super(definition, config); + this.msgFormatter = new FeatureEventFormatterMd( + config.unleashUrl, + LinkStyle.MD, + ); + } + + async handleEvent( + event: IEvent, + parameters: INewRelicParameters, + ): Promise { + const { url, licenseKey, customHeaders, bodyTemplate } = parameters; + const context = { + event, + }; + + let text: string; + if (typeof bodyTemplate === 'string' && bodyTemplate.length > 1) { + text = Mustache.render(bodyTemplate, context); + } else { + text = `%%% \n ${this.msgFormatter.format(event).text} \n %%% `; + } + + const body: INewRelicRequestBody = { + eventType: 'UnleashServiceEvent', + unleashEventType: event.type, + featureName: event.featureName, + environment: event.environment, + createdBy: event.createdBy, + createdByUserId: event.createdByUserId, + createdAt: event.createdAt.getTime(), + ...event.data, + }; + + let extraHeaders = {}; + if (typeof customHeaders === 'string' && customHeaders.length > 1) { + try { + extraHeaders = JSON.parse(customHeaders); + } catch (e) { + this.logger.warn( + `Could not parse the json in the customHeaders parameter. [${customHeaders}]`, + ); + } + } + + const requestOpts = { + method: 'POST', + headers: { + 'Api-Key': licenseKey, + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + ...extraHeaders, + }, + body: await asyncGzip(JSON.stringify(body)), + }; + + const res = await this.fetchRetry(url, requestOpts); + this.logger.info( + `Handled event ${event.type}. Status codes=${res.status}`, + ); + } +}