mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-19 01:17:18 +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 { capitalizeFirst } from 'utils/capitalizeFirst';
|
||||||
|
|
||||||
import dataDogIcon from 'assets/icons/datadog.svg';
|
import dataDogIcon from 'assets/icons/datadog.svg';
|
||||||
|
import newRelicIcon from 'assets/icons/new-relic.svg';
|
||||||
import jiraIcon from 'assets/icons/jira.svg';
|
import jiraIcon from 'assets/icons/jira.svg';
|
||||||
import jiraCommentIcon from 'assets/icons/jira-comment.svg';
|
import jiraCommentIcon from 'assets/icons/jira-comment.svg';
|
||||||
import signals from 'assets/icons/signals.svg';
|
import signals from 'assets/icons/signals.svg';
|
||||||
@ -50,6 +51,7 @@ const integrations: Record<
|
|||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
datadog: { title: 'Datadog', icon: dataDogIcon },
|
datadog: { title: 'Datadog', icon: dataDogIcon },
|
||||||
|
'new-relic': { title: 'New Relic', icon: newRelicIcon },
|
||||||
jira: { title: 'Jira', icon: jiraIcon },
|
jira: { title: 'Jira', icon: jiraIcon },
|
||||||
'jira-comment': { title: 'Jira', icon: jiraCommentIcon },
|
'jira-comment': { title: 'Jira', icon: jiraCommentIcon },
|
||||||
signals: { title: 'Signals', icon: signals },
|
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 SlackAddon from './slack';
|
||||||
import TeamsAddon from './teams';
|
import TeamsAddon from './teams';
|
||||||
import DatadogAddon from './datadog';
|
import DatadogAddon from './datadog';
|
||||||
|
import NewRelicAddon from './new-relic';
|
||||||
import type Addon from './addon';
|
import type Addon from './addon';
|
||||||
import type { LogProvider } from '../logger';
|
import type { LogProvider } from '../logger';
|
||||||
import SlackAppAddon from './slack-app';
|
import SlackAppAddon from './slack-app';
|
||||||
@ -22,6 +23,7 @@ export const getAddons: (args: {
|
|||||||
new SlackAppAddon({ getLogger, unleashUrl }),
|
new SlackAppAddon({ getLogger, unleashUrl }),
|
||||||
new TeamsAddon({ getLogger, unleashUrl }),
|
new TeamsAddon({ getLogger, unleashUrl }),
|
||||||
new DatadogAddon({ getLogger, unleashUrl }),
|
new DatadogAddon({ getLogger, unleashUrl }),
|
||||||
|
new NewRelicAddon({ getLogger, unleashUrl }),
|
||||||
];
|
];
|
||||||
|
|
||||||
return addons.reduce((map, addon) => {
|
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