1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: implement optional json payload and template (#4752)

## About the changes

Adds optional support for specifying JSON templates for datadog message
payload


![image](https://github.com/Unleash/unleash/assets/707867/eb7c838a-7abf-441e-972e-ddd7ada07efa)


### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->

`frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameterEnableWithDropdown.tsx`
- a new component comprising of a text field and a dropdown menu
`src/lib/addons/datadog.ts` - Where the integration is taking place

## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

- Should I have implemented the new component type as a specifiable
addon parameter type in definitions? Felt a bit YAGNI/Premature
- Would like input on naming and the new component etc
This commit is contained in:
David Leek 2023-09-19 13:08:10 +02:00 committed by GitHub
parent be7f0d8b4e
commit bff1bd1026
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 215 additions and 60 deletions

View File

@ -1,19 +1,8 @@
import { TextField, Typography } from '@mui/material';
import { ChangeEventHandler } from 'react';
import { StyledAddonParameterContainer } from '../../IntegrationForm.styles';
import type { AddonParameterSchema, AddonSchema } from 'openapi';
const resolveType = ({ type = 'text', sensitive = false }, value: string) => {
if (sensitive && value === MASKED_VALUE) {
return 'text';
}
if (type === 'textfield') {
return 'text';
}
return type;
};
const MASKED_VALUE = '*****';
import { IntegrationParameterTextField } from './IntegrationParameterTextField';
import { useUiFlag } from 'hooks/useUiFlag';
export interface IIntegrationParameterProps {
parametersErrors: Record<string, string>;
@ -28,41 +17,22 @@ export const IntegrationParameter = ({
parametersErrors,
setParameterValue,
}: IIntegrationParameterProps) => {
const value = config.parameters[definition?.name] || '';
const type = resolveType(
definition,
typeof value === 'string' ? value : ''
);
const error = parametersErrors[definition.name];
const datadogJson = useUiFlag('datadogJsonTemplate');
if (
config.provider === 'datadog' &&
definition.name === 'bodyTemplate' &&
!datadogJson
) {
return null;
}
return (
<StyledAddonParameterContainer>
<TextField
size="small"
style={{ width: '100%' }}
minRows={definition.type === 'textfield' ? 5 : 0}
multiline={definition.type === 'textfield'}
type={type}
label={
<>
{definition.displayName}
{definition.required ? (
<Typography component="span" color="error">
*
</Typography>
) : null}
</>
}
name={definition.name}
placeholder={definition.placeholder || ''}
InputLabelProps={{
shrink: true,
}}
value={value}
error={Boolean(error)}
onChange={setParameterValue(definition.name)}
variant="outlined"
helperText={definition.description}
<IntegrationParameterTextField
config={config}
definition={definition}
parametersErrors={parametersErrors}
setParameterValue={setParameterValue}
/>
</StyledAddonParameterContainer>
);

View File

@ -0,0 +1,70 @@
import { TextField, Typography } from '@mui/material';
import { AddonParameterSchema, AddonSchema } from 'openapi';
import { ChangeEventHandler } from 'react';
import { styled } from '@mui/material';
const MASKED_VALUE = '*****';
const resolveType = ({ type = 'text', sensitive = false }, value: string) => {
if (sensitive && value === MASKED_VALUE) {
return 'text';
}
if (type === 'textfield') {
return 'text';
}
return type;
};
export interface IIntegrationParameterTextFieldProps {
parametersErrors: Record<string, string>;
definition: AddonParameterSchema;
setParameterValue: (param: string) => ChangeEventHandler<HTMLInputElement>;
config: AddonSchema;
}
const StyledTextField = styled(TextField)({
width: '100%',
});
export const IntegrationParameterTextField = ({
definition,
config,
parametersErrors,
setParameterValue,
}: IIntegrationParameterTextFieldProps) => {
const value = config.parameters[definition?.name] || '';
const type = resolveType(
definition,
typeof value === 'string' ? value : ''
);
const error = parametersErrors[definition.name];
return (
<StyledTextField
size="small"
minRows={definition.type === 'textfield' ? 5 : 0}
multiline={definition.type === 'textfield'}
type={type}
label={
<>
{definition.displayName}
{definition.required ? (
<Typography component="span" color="error">
*
</Typography>
) : null}
</>
}
name={definition.name}
placeholder={definition.placeholder || ''}
InputLabelProps={{
shrink: true,
}}
value={value}
error={Boolean(error)}
onChange={setParameterValue(definition.name)}
variant="outlined"
helperText={definition.description}
/>
);
};

View File

@ -75,6 +75,7 @@ exports[`should create default config 1`] = `
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"customRootRolesKillSwitch": false,
"datadogJsonTemplate": false,
"demo": false,
"dependentFeatures": false,
"disableBulkToggle": false,
@ -114,6 +115,7 @@ exports[`should create default config 1`] = `
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"customRootRolesKillSwitch": false,
"datadogJsonTemplate": false,
"demo": false,
"dependentFeatures": false,
"disableBulkToggle": false,

View File

@ -8,6 +8,8 @@ exports[`Should call datadog webhook 1`] = `"{"text":"%%% \\n some@user.com crea
exports[`Should call datadog webhook for toggled environment 1`] = `"{"text":"%%% \\n some@user.com *disabled* [some-toggle](http://some-url.com/projects/default/features/some-toggle) in *development* environment in project *default* \\n %%% ","title":"Unleash notification update"}"`;
exports[`Should call datadog webhook with JSON when template set 1`] = `"{"text":"{\\n \\"event\\": \\"feature-created\\",\\n \\"createdBy\\": \\"some@user.com\\"\\n}","title":"Unleash notification update"}"`;
exports[`Should include customHeaders in headers when calling service 1`] = `"{"text":"%%% \\n some@user.com *disabled* [some-toggle](http://some-url.com/projects/default/features/some-toggle) in *development* environment in project *default* \\n %%% ","title":"Unleash notification update"}"`;
exports[`Should include customHeaders in headers when calling service 2`] = `

View File

@ -62,6 +62,21 @@ const dataDogDefinition: IAddonDefinition = {
sensitive: true,
type: 'textfield',
},
{
name: 'bodyTemplate',
displayName: 'Body template',
placeholder: `{
"event": "{{event.type}}",
"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,

View File

@ -39,6 +39,11 @@ test('Should call datadog webhook', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 1,
@ -68,6 +73,11 @@ test('Should call datadog webhook for archived toggle', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 2,
@ -95,6 +105,11 @@ test('Should call datadog webhook for archived toggle with project info', async
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 2,
@ -123,6 +138,11 @@ test(`Should call datadog webhook for toggled environment`, async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 2,
@ -153,6 +173,11 @@ test(`Should include customHeaders in headers when calling service`, async () =>
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 2,
@ -184,6 +209,11 @@ test(`Should not include source_type_name when included in the config`, async ()
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(false),
},
});
const event: IEvent = {
id: 2,
@ -213,3 +243,39 @@ test(`Should not include source_type_name when included in the config`, async ()
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
expect(fetchRetryCalls[0].options.headers).toMatchSnapshot();
});
test('Should call datadog webhook with JSON when template set', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
flagResolver: {
getAll: jest.fn().mockReturnValue([]),
getVariant: jest.fn(),
isEnabled: jest.fn().mockReturnValue(true),
},
});
const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://api.datadoghq.com/api/v1/events',
apiKey: 'fakeKey',
bodyTemplate:
'{\n "event": "{{event.type}}",\n "createdBy": "{{event.createdBy}}"\n}',
};
await addon.handleEvent(event, parameters);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});

View File

@ -1,6 +1,8 @@
import Addon from './addon';
import definition from './datadog-definition';
import Mustache from 'mustache';
import { IFlagResolver } from '../types/experimental';
import { IAddonConfig } from '../types/model';
import {
FeatureEventFormatter,
@ -14,6 +16,7 @@ interface IDatadogParameters {
apiKey: string;
sourceTypeName?: string;
customHeaders?: string;
bodyTemplate?: string;
}
interface DDRequestBody {
@ -23,15 +26,22 @@ interface DDRequestBody {
source_type_name?: string;
}
export interface IDatadogAddonConfig extends IAddonConfig {
flagResolver: IFlagResolver;
}
export default class DatadogAddon extends Addon {
private msgFormatter: FeatureEventFormatter;
constructor(config: IAddonConfig) {
private flagResolver: IFlagResolver;
constructor(config: IDatadogAddonConfig) {
super(definition, config);
this.msgFormatter = new FeatureEventFormatterMd(
config.unleashUrl,
LinkStyle.MD,
);
this.flagResolver = config.flagResolver;
}
async handleEvent(
@ -43,15 +53,29 @@ export default class DatadogAddon extends Addon {
apiKey,
sourceTypeName,
customHeaders,
bodyTemplate,
} = parameters;
const text = this.msgFormatter.format(event);
const context = {
event,
};
let text;
if (
this.flagResolver.isEnabled('datadogJsonTemplate') &&
typeof bodyTemplate === 'string' &&
bodyTemplate.length > 1
) {
text = Mustache.render(bodyTemplate, context);
} else {
text = `%%% \n ${this.msgFormatter.format(event)} \n %%% `;
}
const { tags: eventTags } = event;
const tags =
eventTags && eventTags.map((tag) => `${tag.type}:${tag.value}`);
const body: DDRequestBody = {
text: `%%% \n ${text} \n %%% `,
text: text,
title: 'Unleash notification update',
tags,
};

View File

@ -29,7 +29,7 @@ export const getAddons: (args: {
new Webhook({ getLogger }),
slackAddon,
new TeamsAddon({ getLogger, unleashUrl }),
new DatadogAddon({ getLogger, unleashUrl }),
new DatadogAddon({ getLogger, unleashUrl, flagResolver }),
];
if (slackAppAddonEnabled) {

View File

@ -65,6 +65,16 @@ const webhookDefinition: IAddonDefinition = {
required: false,
sensitive: true,
},
{
name: 'customHeaders',
displayName: 'Extra HTTP Headers',
placeholder:
'{\n"ISTIO_USER_KEY": "hunter2",\n"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"\n}',
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',
@ -80,16 +90,6 @@ const webhookDefinition: IAddonDefinition = {
required: false,
sensitive: false,
},
{
name: 'customHeaders',
displayName: 'Extra HTTP Headers',
placeholder:
'{\n"ISTIO_USER_KEY": "hunter2",\n"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"\n}',
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',
},
],
events: [
FEATURE_CREATED,

View File

@ -31,7 +31,8 @@ export type IFlagKey =
| 'variantTypeNumber'
| 'accessOverview'
| 'privateProjects'
| 'dependentFeatures';
| 'dependentFeatures'
| 'datadogJsonTemplate';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -147,6 +148,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_ACCESS_OVERVIEW,
false,
),
datadogJsonTemplate: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_DATADOG_JSON_TEMPLATE,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -45,6 +45,7 @@ process.nextTick(async () => {
variantTypeNumber: true,
privateProjects: false,
accessOverview: true,
datadogJsonTemplate: true,
},
},
authentication: {