From bff1bd10262260d4ab867c5ddf8fac94101e3234 Mon Sep 17 00:00:00 2001 From: David Leek Date: Tue, 19 Sep 2023 13:08:10 +0200 Subject: [PATCH] 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 `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 - 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 --- .../IntegrationParameter.tsx | 60 ++++------------ .../IntegrationParameterTextField.tsx | 70 +++++++++++++++++++ .../__snapshots__/create-config.test.ts.snap | 2 + .../addons/__snapshots__/datadog.test.ts.snap | 2 + src/lib/addons/datadog-definition.ts | 15 ++++ src/lib/addons/datadog.test.ts | 66 +++++++++++++++++ src/lib/addons/datadog.ts | 30 +++++++- src/lib/addons/index.ts | 2 +- src/lib/addons/webhook-definition.ts | 20 +++--- src/lib/types/experimental.ts | 7 +- src/server-dev.ts | 1 + 11 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameterTextField.tsx diff --git a/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameter.tsx b/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameter.tsx index 6b63c7ed83..18da956586 100644 --- a/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameter.tsx +++ b/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameter.tsx @@ -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; @@ -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 ( - - {definition.displayName} - {definition.required ? ( - - * - - ) : null} - - } - name={definition.name} - placeholder={definition.placeholder || ''} - InputLabelProps={{ - shrink: true, - }} - value={value} - error={Boolean(error)} - onChange={setParameterValue(definition.name)} - variant="outlined" - helperText={definition.description} + ); diff --git a/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameterTextField.tsx b/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameterTextField.tsx new file mode 100644 index 0000000000..dbe59e4319 --- /dev/null +++ b/frontend/src/component/integrations/IntegrationForm/IntegrationParameters/IntegrationParameter/IntegrationParameterTextField.tsx @@ -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; + definition: AddonParameterSchema; + setParameterValue: (param: string) => ChangeEventHandler; + 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 ( + + {definition.displayName} + {definition.required ? ( + + * + + ) : null} + + } + name={definition.name} + placeholder={definition.placeholder || ''} + InputLabelProps={{ + shrink: true, + }} + value={value} + error={Boolean(error)} + onChange={setParameterValue(definition.name)} + variant="outlined" + helperText={definition.description} + /> + ); +}; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 6aff82af2d..aacb6303a1 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, diff --git a/src/lib/addons/__snapshots__/datadog.test.ts.snap b/src/lib/addons/__snapshots__/datadog.test.ts.snap index c2031adf15..dd80d0d460 100644 --- a/src/lib/addons/__snapshots__/datadog.test.ts.snap +++ b/src/lib/addons/__snapshots__/datadog.test.ts.snap @@ -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`] = ` diff --git a/src/lib/addons/datadog-definition.ts b/src/lib/addons/datadog-definition.ts index 499ccbb0e1..37ef23a9c9 100644 --- a/src/lib/addons/datadog-definition.ts +++ b/src/lib/addons/datadog-definition.ts @@ -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, diff --git a/src/lib/addons/datadog.test.ts b/src/lib/addons/datadog.test.ts index 43857afa33..cc08f2e7e0 100644 --- a/src/lib/addons/datadog.test.ts +++ b/src/lib/addons/datadog.test.ts @@ -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(); +}); diff --git a/src/lib/addons/datadog.ts b/src/lib/addons/datadog.ts index ee03c93d34..cc044ce771 100644 --- a/src/lib/addons/datadog.ts +++ b/src/lib/addons/datadog.ts @@ -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, }; diff --git a/src/lib/addons/index.ts b/src/lib/addons/index.ts index ea67ecc721..c6376dcd8a 100644 --- a/src/lib/addons/index.ts +++ b/src/lib/addons/index.ts @@ -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) { diff --git a/src/lib/addons/webhook-definition.ts b/src/lib/addons/webhook-definition.ts index 3c3c07957c..e92dc6892a 100644 --- a/src/lib/addons/webhook-definition.ts +++ b/src/lib/addons/webhook-definition.ts @@ -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, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index d123c57c56..8aed0e0e5e 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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 = { diff --git a/src/server-dev.ts b/src/server-dev.ts index a041f20eea..c4d3c9b0ca 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -45,6 +45,7 @@ process.nextTick(async () => { variantTypeNumber: true, privateProjects: false, accessOverview: true, + datadogJsonTemplate: true, }, }, authentication: {