1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat: New Relic integration (#7492)

## About the changes
Add New Relic integration based on issue #878.


![image](https://github.com/Unleash/unleash/assets/1612455/05523b73-398b-413d-b760-26bf2feec2db)


![image](https://github.com/Unleash/unleash/assets/1612455/1ec01f52-0c1a-46a7-aa5b-5ca80004dcf8)


<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #878
This commit is contained in:
Alexandru Savin 2024-07-05 15:16:00 +02:00 committed by GitHub
parent 9ad7266aa1
commit 9fae7801ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 423 additions and 0 deletions

View File

@ -0,0 +1,5 @@
<svg viewBox="0 0 832.8 959.8" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M672.6 332.3l160.2-92.4v480L416.4 959.8V775.2l256.2-147.6z" fill="#00ac69"/>
<path d="M416.4 184.6L160.2 332.3 0 239.9 416.4 0l416.4 239.9-160.2 92.4z" fill="#1ce783"/>
<path d="M256.2 572.3L0 424.6V239.9l416.4 240v479.9l-160.2-92.2z" fill="#1d252c"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -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 },

View File

@ -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",
}
`;

View File

@ -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) => {

View File

@ -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;

View File

@ -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<IEvent>;
partialParameters?: Partial<INewRelicParameters>;
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());
},
);

View File

@ -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<void> {
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}`,
);
}
}