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.   <!-- 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:
parent
9ad7266aa1
commit
9fae7801ed
5
frontend/src/assets/icons/new-relic.svg
Normal file
5
frontend/src/assets/icons/new-relic.svg
Normal 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 |
@ -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 },
|
||||
|
50
src/lib/addons/__snapshots__/new-relic.test.ts.snap
Normal file
50
src/lib/addons/__snapshots__/new-relic.test.ts.snap
Normal 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",
|
||||
}
|
||||
`;
|
@ -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) => {
|
||||
|
102
src/lib/addons/new-relic-definition.ts
Normal file
102
src/lib/addons/new-relic-definition.ts
Normal 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;
|
164
src/lib/addons/new-relic.test.ts
Normal file
164
src/lib/addons/new-relic.test.ts
Normal 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());
|
||||
},
|
||||
);
|
98
src/lib/addons/new-relic.ts
Normal file
98
src/lib/addons/new-relic.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user