mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: implement optional json payload and template (#4752)
## About the changes Adds optional support for specifying JSON templates for datadog message payload  ### 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:
		
							parent
							
								
									be7f0d8b4e
								
							
						
					
					
						commit
						bff1bd1026
					
				| @ -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> | ||||
|     ); | ||||
|  | ||||
| @ -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} | ||||
|         /> | ||||
|     ); | ||||
| }; | ||||
| @ -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, | ||||
|  | ||||
| @ -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`] = ` | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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(); | ||||
| }); | ||||
|  | ||||
| @ -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, | ||||
|         }; | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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 = { | ||||
|  | ||||
| @ -45,6 +45,7 @@ process.nextTick(async () => { | ||||
|                         variantTypeNumber: true, | ||||
|                         privateProjects: false, | ||||
|                         accessOverview: true, | ||||
|                         datadogJsonTemplate: true, | ||||
|                     }, | ||||
|                 }, | ||||
|                 authentication: { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user