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

feat: improve slack app addon scalability (#4284)

https://linear.app/unleash/issue/2-1237/explore-slack-app-addon-scalability-and-limitations

Relevant document:
https://linear.app/unleash/document/894e12b7-802c-4bc5-8c22-75af0e66fa4b

 - Implements 30s cache layer for Slack channels;
 - Adds error logging;
 - Adds respective tests;
 - Slight refactors and improvements for overall robustness;

---------

Co-authored-by: Gastón Fournier <gaston@getunleash.io>
This commit is contained in:
Nuno Góis 2023-07-20 13:37:06 +01:00 committed by GitHub
parent a53d50148b
commit bb58a516bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 291 additions and 99 deletions

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should not post to unexisting tagged channels 1`] = ` exports[`SlackAppAddon should not post to unexisting tagged channels 1`] = `
{ {
"attachments": [ "attachments": [
{ {
@ -21,7 +21,7 @@ exports[`Should not post to unexisting tagged channels 1`] = `
} }
`; `;
exports[`Should post message when feature is toggled 1`] = ` exports[`SlackAppAddon should post message when feature is toggled 1`] = `
{ {
"attachments": [ "attachments": [
{ {
@ -42,7 +42,7 @@ exports[`Should post message when feature is toggled 1`] = `
} }
`; `;
exports[`Should post to all channels in tags 1`] = ` exports[`SlackAppAddon should post to all channels in tags 1`] = `
{ {
"attachments": [ "attachments": [
{ {
@ -63,7 +63,7 @@ exports[`Should post to all channels in tags 1`] = `
} }
`; `;
exports[`Should post to all channels in tags 2`] = ` exports[`SlackAppAddon should post to all channels in tags 2`] = `
{ {
"attachments": [ "attachments": [
{ {

View File

@ -66,4 +66,6 @@ export default abstract class Addon {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
abstract handleEvent(event: IEvent, parameters: any): Promise<void>; abstract handleEvent(event: IEvent, parameters: any): Promise<void>;
destroy?(): void;
} }

View File

@ -1,16 +1,18 @@
import { IEvent, FEATURE_ENVIRONMENT_ENABLED } from '../types/events'; import { IEvent, FEATURE_ENVIRONMENT_ENABLED } from '../types/events';
import SlackAppAddon from './slack-app'; import SlackAppAddon from './slack-app';
import { ChatPostMessageArguments, ErrorCode } from '@slack/web-api';
import noLogger from '../../test/fixtures/no-logger';
import { ChatPostMessageArguments } from '@slack/web-api';
const accessToken = 'test-access-token';
const slackApiCalls: ChatPostMessageArguments[] = []; const slackApiCalls: ChatPostMessageArguments[] = [];
const conversationsList = jest.fn();
let postMessage = jest.fn().mockImplementation((options) => {
slackApiCalls.push(options);
return Promise.resolve();
});
jest.mock('@slack/web-api', () => ({ jest.mock('@slack/web-api', () => ({
WebClient: jest.fn().mockImplementation(() => ({ WebClient: jest.fn().mockImplementation(() => ({
conversations: { conversations: {
list: () => ({ list: conversationsList.mockImplementation(() => ({
channels: [ channels: [
{ {
id: 1, id: 1,
@ -20,27 +22,42 @@ jest.mock('@slack/web-api', () => ({
id: 2, id: 2,
name: 'another-channel-1', name: 'another-channel-1',
}, },
{
id: 3,
name: 'another-channel-2',
},
], ],
}), })),
}, },
chat: { chat: {
postMessage: jest.fn().mockImplementation((options) => { postMessage,
slackApiCalls.push(options);
return Promise.resolve();
}),
}, },
on: jest.fn(),
})), })),
ErrorCode: {
PlatformError: 'slack_webapi_platform_error',
},
WebClientEvent: {
RATE_LIMITED: 'rate_limited',
},
})); }));
beforeEach(() => { describe('SlackAppAddon', () => {
slackApiCalls.length = 0; let addon;
}); const accessToken = 'test-access-token';
const loggerMock = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
};
const getLogger = jest.fn(() => loggerMock);
const mockError = {
code: ErrorCode.PlatformError,
data: 'Platform error message',
};
test('Should post message when feature is toggled', async () => {
const addon = new SlackAppAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const event: IEvent = { const event: IEvent = {
id: 1, id: 1,
createdAt: new Date(), createdAt: new Date(),
@ -58,66 +75,120 @@ test('Should post message when feature is toggled', async () => {
tags: [{ type: 'slack', value: 'general' }], tags: [{ type: 'slack', value: 'general' }],
}; };
await addon.handleEvent(event, { accessToken }); beforeEach(() => {
expect(slackApiCalls.length).toBe(1); jest.useFakeTimers();
expect(slackApiCalls[0].channel).toBe(1); slackApiCalls.length = 0;
expect(slackApiCalls[0]).toMatchSnapshot(); conversationsList.mockClear();
}); addon = new SlackAppAddon({
getLogger,
test('Should post to all channels in tags', async () => { unleashUrl: 'http://some-url.com',
const addon = new SlackAppAddon({ });
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
}); });
const event: IEvent = {
id: 2,
createdAt: new Date(),
type: FEATURE_ENVIRONMENT_ENABLED,
createdBy: 'some@user.com',
project: 'default',
featureName: 'some-toggle',
environment: 'development',
data: {
name: 'some-toggle',
},
tags: [
{ type: 'slack', value: 'general' },
{ type: 'slack', value: 'another-channel-1' },
],
};
await addon.handleEvent(event, { accessToken }); afterEach(() => {
expect(slackApiCalls.length).toBe(2); jest.useRealTimers();
expect(slackApiCalls[0].channel).toBe(1); addon.destroy();
expect(slackApiCalls[0]).toMatchSnapshot();
expect(slackApiCalls[1].channel).toBe(2);
expect(slackApiCalls[1]).toMatchSnapshot();
});
test('Should not post to unexisting tagged channels', async () => {
const addon = new SlackAppAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
}); });
const event: IEvent = {
id: 3,
createdAt: new Date(),
type: FEATURE_ENVIRONMENT_ENABLED,
createdBy: 'some@user.com',
project: 'default',
featureName: 'some-toggle',
environment: 'development',
data: {
name: 'some-toggle',
},
tags: [
{ type: 'slack', value: 'random' },
{ type: 'slack', value: 'another-channel-1' },
],
};
await addon.handleEvent(event, { accessToken }); it('should post message when feature is toggled', async () => {
expect(slackApiCalls.length).toBe(1); await addon.handleEvent(event, { accessToken });
expect(slackApiCalls[0].channel).toBe(2);
expect(slackApiCalls[0]).toMatchSnapshot(); expect(slackApiCalls.length).toBe(1);
expect(slackApiCalls[0].channel).toBe(1);
expect(slackApiCalls[0]).toMatchSnapshot();
});
it('should post to all channels in tags', async () => {
const eventWith2Tags: IEvent = {
...event,
tags: [
{ type: 'slack', value: 'general' },
{ type: 'slack', value: 'another-channel-1' },
],
};
await addon.handleEvent(eventWith2Tags, { accessToken });
expect(slackApiCalls.length).toBe(2);
expect(slackApiCalls[0].channel).toBe(1);
expect(slackApiCalls[0]).toMatchSnapshot();
expect(slackApiCalls[1].channel).toBe(2);
expect(slackApiCalls[1]).toMatchSnapshot();
});
it('should not post to unexisting tagged channels', async () => {
const eventWithUnexistingTaggedChannel: IEvent = {
...event,
tags: [
{ type: 'slack', value: 'random' },
{ type: 'slack', value: 'another-channel-1' },
],
};
await addon.handleEvent(eventWithUnexistingTaggedChannel, {
accessToken,
});
expect(slackApiCalls.length).toBe(1);
expect(slackApiCalls[0].channel).toBe(2);
expect(slackApiCalls[0]).toMatchSnapshot();
});
it('should cache Slack channels', async () => {
await addon.handleEvent(event, { accessToken });
await addon.handleEvent(event, { accessToken });
expect(slackApiCalls.length).toBe(2);
expect(conversationsList).toHaveBeenCalledTimes(1);
});
it('should refresh Slack channels cache after 30 seconds', async () => {
await addon.handleEvent(event, { accessToken });
jest.advanceTimersByTime(30000);
await addon.handleEvent(event, { accessToken });
expect(slackApiCalls.length).toBe(2);
expect(conversationsList).toHaveBeenCalledTimes(2);
});
it('should log error when an API call fails', async () => {
postMessage = jest.fn().mockRejectedValue(mockError);
await addon.handleEvent(event, { accessToken });
expect(loggerMock.warn).toHaveBeenCalledWith(
`Error handling event ${event.type}. A platform error occurred: Platform error message`,
expect.any(Object),
);
});
it('should handle rejections in chat.postMessage', async () => {
const eventWith3Tags: IEvent = {
...event,
tags: [
{ type: 'slack', value: 'general' },
{ type: 'slack', value: 'another-channel-1' },
{ type: 'slack', value: 'another-channel-2' },
],
};
postMessage = jest
.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true })
.mockRejectedValueOnce(mockError);
await addon.handleEvent(eventWith3Tags, { accessToken });
expect(postMessage).toHaveBeenCalledTimes(3);
expect(loggerMock.warn).toHaveBeenCalledWith(
`Error handling event ${FEATURE_ENVIRONMENT_ENABLED}. A platform error occurred: Platform error message`,
expect.any(Object),
);
expect(loggerMock.info).toHaveBeenCalledWith(
`Handled event ${FEATURE_ENVIRONMENT_ENABLED} dispatching 2 out of 3 messages successfully.`,
);
});
}); });

View File

@ -1,4 +1,14 @@
import { WebClient } from '@slack/web-api'; import {
WebClient,
ConversationsListResponse,
ErrorCode,
WebClientEvent,
CodedError,
WebAPIPlatformError,
WebAPIRequestError,
WebAPIRateLimitedError,
WebAPIHTTPError,
} from '@slack/web-api';
import Addon from './addon'; import Addon from './addon';
import slackAppDefinition from './slack-app-definition'; import slackAppDefinition from './slack-app-definition';
@ -11,46 +21,86 @@ import {
} from './feature-event-formatter-md'; } from './feature-event-formatter-md';
import { IEvent } from '../types/events'; import { IEvent } from '../types/events';
const CACHE_SECONDS = 30;
interface ISlackAppAddonParameters { interface ISlackAppAddonParameters {
accessToken: string; accessToken: string;
} }
export default class SlackAppAddon extends Addon { export default class SlackAppAddon extends Addon {
private msgFormatter: FeatureEventFormatter; private msgFormatter: FeatureEventFormatter;
private accessToken?: string;
private slackClient?: WebClient; private slackClient?: WebClient;
private slackChannels?: ConversationsListResponse['channels'];
private slackChannelsCacheTimeout?: NodeJS.Timeout;
constructor(args: IAddonConfig) { constructor(args: IAddonConfig) {
super(slackAppDefinition, args); super(slackAppDefinition, args);
this.msgFormatter = new FeatureEventFormatterMd( this.msgFormatter = new FeatureEventFormatterMd(
args.unleashUrl, args.unleashUrl,
LinkStyle.SLACK, LinkStyle.SLACK,
); );
this.startCacheInvalidation();
} }
async handleEvent( async handleEvent(
event: IEvent, event: IEvent,
parameters: ISlackAppAddonParameters, parameters: ISlackAppAddonParameters,
): Promise<void> { ): Promise<void> {
const { accessToken } = parameters; try {
const { accessToken } = parameters;
if (!accessToken) {
this.logger.warn('No access token provided.');
return;
}
if (!accessToken) return; const taggedChannels = this.findTaggedChannels(event);
if (!taggedChannels.length) {
this.logger.debug(
`No Slack channels tagged for event ${event.type}`,
event,
);
return;
}
if (!this.slackClient) { if (!this.slackClient || this.accessToken !== accessToken) {
this.slackClient = new WebClient(accessToken); const client = new WebClient(accessToken);
} client.on(WebClientEvent.RATE_LIMITED, (numSeconds) => {
this.logger.debug(
`Rate limit reached for event ${event.type}. Retry scheduled after ${numSeconds} seconds`,
);
});
this.slackClient = client;
this.accessToken = accessToken;
}
const slackChannels = await this.slackClient.conversations.list({ if (!this.slackChannels) {
types: 'public_channel,private_channel', const slackConversationsList =
}); await this.slackClient.conversations.list({
const taggedChannels = this.findTaggedChannels(event); types: 'public_channel,private_channel',
});
this.slackChannels = slackConversationsList.channels || [];
this.logger.debug(
`Fetched ${this.slackChannels.length} Slack channels`,
);
}
if (slackChannels.channels?.length && taggedChannels.length) { const currentSlackChannels = [...this.slackChannels];
const slackChannelsToPostTo = slackChannels.channels.filter( if (!currentSlackChannels.length) {
({ id, name }) => id && name && taggedChannels.includes(name), this.logger.warn('No Slack channels found.');
); return;
}
const text = this.msgFormatter.format(event); const text = this.msgFormatter.format(event);
const featureLink = this.msgFormatter.featureLink(event); const url = this.msgFormatter.featureLink(event);
const slackChannelsToPostTo = currentSlackChannels.filter(
({ id, name }) => id && name && taggedChannels.includes(name),
);
const requests = slackChannelsToPostTo.map(({ id }) => const requests = slackChannelsToPostTo.map(({ id }) =>
this.slackClient!.chat.postMessage({ this.slackClient!.chat.postMessage({
@ -65,7 +115,7 @@ export default class SlackAppAddon extends Addon {
type: 'button', type: 'button',
value: 'featureToggle', value: 'featureToggle',
style: 'primary', style: 'primary',
url: featureLink, url,
}, },
], ],
}, },
@ -73,8 +123,22 @@ export default class SlackAppAddon extends Addon {
}), }),
); );
await Promise.all(requests); const results = await Promise.allSettled(requests);
this.logger.info(`Handled event ${event.type}.`);
results
.filter(({ status }) => status === 'rejected')
.map(({ reason }: PromiseRejectedResult) =>
this.logError(event, reason),
);
this.logger.info(
`Handled event ${event.type} dispatching ${
results.filter(({ status }) => status === 'fulfilled')
.length
} out of ${requests.length} messages successfully.`,
);
} catch (error) {
this.logError(event, error);
} }
} }
@ -86,6 +150,54 @@ export default class SlackAppAddon extends Addon {
} }
return []; return [];
} }
startCacheInvalidation(): void {
this.slackChannelsCacheTimeout = setInterval(() => {
this.slackChannels = undefined;
}, CACHE_SECONDS * 1000);
}
logError(event: IEvent, error: Error | CodedError): void {
if (!('code' in error)) {
this.logger.warn(`Error handling event ${event.type}.`, error);
return;
}
if (error.code === ErrorCode.PlatformError) {
const { data } = error as WebAPIPlatformError;
this.logger.warn(
`Error handling event ${event.type}. A platform error occurred: ${data}`,
error,
);
} else if (error.code === ErrorCode.RequestError) {
const { original } = error as WebAPIRequestError;
this.logger.warn(
`Error handling event ${event.type}. A request error occurred: ${original}`,
error,
);
} else if (error.code === ErrorCode.RateLimitedError) {
const { retryAfter } = error as WebAPIRateLimitedError;
this.logger.warn(
`Error handling event ${event.type}. A rate limit error occurred: retry after ${retryAfter} seconds`,
error,
);
} else if (error.code === ErrorCode.HTTPError) {
const { statusCode } = error as WebAPIHTTPError;
this.logger.warn(
`Error handling event ${event.type}. An HTTP error occurred: status code ${statusCode}`,
error,
);
} else {
this.logger.warn(`Error handling event ${event.type}.`, error);
}
}
destroy(): void {
if (this.slackChannelsCacheTimeout) {
clearInterval(this.slackChannelsCacheTimeout);
this.slackChannelsCacheTimeout = undefined;
}
}
} }
module.exports = SlackAppAddon; module.exports = SlackAppAddon;

View File

@ -59,6 +59,7 @@ async function createApp(
stores.clientInstanceStore.destroy(); stores.clientInstanceStore.destroy();
services.clientMetricsServiceV2.destroy(); services.clientMetricsServiceV2.destroy();
services.proxyService.destroy(); services.proxyService.destroy();
services.addonService.destroy();
await db.destroy(); await db.destroy();
}; };

View File

@ -301,4 +301,10 @@ export default class AddonService {
} }
return true; return true;
} }
destroy(): void {
Object.values(this.addonProviders).forEach((addon) =>
addon.destroy?.(),
);
}
} }