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}`,
+ );
+ }
+}