diff --git a/src/lib/addons/addon.test.ts b/src/lib/addons/addon.test.ts index 46799c618a..8ce143c34e 100644 --- a/src/lib/addons/addon.test.ts +++ b/src/lib/addons/addon.test.ts @@ -4,6 +4,7 @@ import noLogger from '../../test/fixtures/no-logger'; import SlackAddon from './slack'; import type { IAddonConfig, IFlagResolver } from '../types'; import type { IntegrationEventsService } from '../services'; +import type EventEmitter from 'events'; beforeEach(() => { nock.disableNetConnect(); @@ -16,6 +17,7 @@ const ARGS: IAddonConfig = { unleashUrl: url, integrationEventsService: {} as IntegrationEventsService, flagResolver: {} as IFlagResolver, + eventBus: {} as EventEmitter, }; test('Does not retry if request succeeds', async () => { diff --git a/src/lib/addons/addon.ts b/src/lib/addons/addon.ts index 08c42b6ff3..6ba3dc7405 100644 --- a/src/lib/addons/addon.ts +++ b/src/lib/addons/addon.ts @@ -5,6 +5,7 @@ import type { IAddonConfig, IAddonDefinition } from '../types/model'; import type { IEvent } from '../types/events'; import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; import type { IntegrationEventWriteModel } from '../features/integration-events/integration-events-store'; +import type EventEmitter from 'events'; import type { IFlagResolver } from '../types'; export default abstract class Addon { @@ -16,11 +17,18 @@ export default abstract class Addon { integrationEventsService: IntegrationEventsService; + eventBus: EventEmitter; + flagResolver: IFlagResolver; constructor( definition: IAddonDefinition, - { getLogger, integrationEventsService, flagResolver }: IAddonConfig, + { + getLogger, + integrationEventsService, + flagResolver, + eventBus, + }: IAddonConfig, ) { this.logger = getLogger(`addon/${definition.name}`); const { error } = addonDefinitionSchema.validate(definition); @@ -34,6 +42,7 @@ export default abstract class Addon { this._name = definition.name; this._definition = definition; this.integrationEventsService = integrationEventsService; + this.eventBus = eventBus; this.flagResolver = flagResolver; } diff --git a/src/lib/addons/datadog.test.ts b/src/lib/addons/datadog.test.ts index 76c06a3c84..933643146d 100644 --- a/src/lib/addons/datadog.test.ts +++ b/src/lib/addons/datadog.test.ts @@ -10,6 +10,7 @@ import DatadogAddon from './datadog'; import noLogger from '../../test/fixtures/no-logger'; import { + type IFlagKey, serializeDates, type IAddonConfig, type IFlagResolver, @@ -24,7 +25,8 @@ const ARGS: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; jest.mock( diff --git a/src/lib/addons/datadog.ts b/src/lib/addons/datadog.ts index 1af3ab600b..8efad9df7c 100644 --- a/src/lib/addons/datadog.ts +++ b/src/lib/addons/datadog.ts @@ -2,7 +2,11 @@ import Addon from './addon'; import definition from './datadog-definition'; import Mustache from 'mustache'; -import { type IAddonConfig, serializeDates } from '../types'; +import { + type IAddonConfig, + type IFlagResolver, + serializeDates, +} from '../types'; import { type FeatureEventFormatter, FeatureEventFormatterMd, @@ -10,6 +14,7 @@ import { } from './feature-event-formatter-md'; import type { IEvent } from '../types/events'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; interface IDatadogParameters { url: string; @@ -29,12 +34,15 @@ interface DDRequestBody { export default class DatadogAddon extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + constructor(config: IAddonConfig) { super(definition, config); this.msgFormatter = new FeatureEventFormatterMd( config.unleashUrl, LinkStyle.MD, ); + this.flagResolver = config.flagResolver; } async handleEvent( @@ -107,6 +115,13 @@ export default class DatadogAddon extends Addon { state = 'failed'; const failedMessage = `Datadog Events API request failed with status code: ${res.status}.`; stateDetails.push(failedMessage); + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'datadog', + }); + } + this.logger.warn(failedMessage); } diff --git a/src/lib/addons/new-relic.test.ts b/src/lib/addons/new-relic.test.ts index cc0d9b9ade..1009126706 100644 --- a/src/lib/addons/new-relic.test.ts +++ b/src/lib/addons/new-relic.test.ts @@ -6,6 +6,7 @@ import { type IAddonConfig, type IEvent, serializeDates, + type IFlagKey, } from '../types'; import type { Logger } from '../logger'; @@ -26,7 +27,8 @@ const ARGS: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; jest.mock( diff --git a/src/lib/addons/new-relic.ts b/src/lib/addons/new-relic.ts index 4dcc69beee..d9dcf44c5b 100644 --- a/src/lib/addons/new-relic.ts +++ b/src/lib/addons/new-relic.ts @@ -6,6 +6,7 @@ import { type IAddonConfig, type IEvent, type IEventType, + type IFlagResolver, serializeDates, } from '../types'; import { @@ -16,6 +17,7 @@ import { import { gzip } from 'node:zlib'; import { promisify } from 'util'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; const asyncGzip = promisify(gzip); @@ -39,12 +41,15 @@ interface INewRelicRequestBody { export default class NewRelicAddon extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + constructor(config: IAddonConfig) { super(definition, config); this.msgFormatter = new FeatureEventFormatterMd( config.unleashUrl, LinkStyle.MD, ); + this.flagResolver = config.flagResolver; } async handleEvent( @@ -117,6 +122,13 @@ export default class NewRelicAddon extends Addon { this.logger.warn(failedMessage); } + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'new-relic', + }); + } + this.registerEvent({ integrationId, state, diff --git a/src/lib/addons/slack-app.test.ts b/src/lib/addons/slack-app.test.ts index fb4ec4958f..cac4d98dd8 100644 --- a/src/lib/addons/slack-app.test.ts +++ b/src/lib/addons/slack-app.test.ts @@ -3,6 +3,7 @@ import SlackAppAddon from './slack-app'; import { type ChatPostMessageArguments, ErrorCode } from '@slack/web-api'; import { type IAddonConfig, + type IFlagKey, type IFlagResolver, serializeDates, SYSTEM_USER_ID, @@ -28,7 +29,8 @@ const ARGS: IAddonConfig = { getLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; let postMessage = jest.fn().mockImplementation((options) => { diff --git a/src/lib/addons/slack-app.ts b/src/lib/addons/slack-app.ts index 73220843b5..8b9d05e48b 100644 --- a/src/lib/addons/slack-app.ts +++ b/src/lib/addons/slack-app.ts @@ -13,7 +13,11 @@ import { import Addon from './addon'; import slackAppDefinition from './slack-app-definition'; -import { type IAddonConfig, serializeDates } from '../types'; +import { + type IAddonConfig, + type IFlagResolver, + serializeDates, +} from '../types'; import { type FeatureEventFormatter, FeatureEventFormatterMd, @@ -21,6 +25,7 @@ import { } from './feature-event-formatter-md'; import type { IEvent } from '../types/events'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; interface ISlackAppAddonParameters { accessToken: string; @@ -30,6 +35,8 @@ interface ISlackAppAddonParameters { export default class SlackAppAddon extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + private accessToken?: string; private slackClient?: WebClient; @@ -40,6 +47,7 @@ export default class SlackAppAddon extends Addon { args.unleashUrl, LinkStyle.SLACK, ); + this.flagResolver = args.flagResolver; } async handleEvent( @@ -168,6 +176,13 @@ export default class SlackAppAddon extends Addon { stateDetails.push(eventErrorMessage); this.logger.warn(eventErrorMessage); const errorMessage = this.parseError(error); + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'slack-app', + }); + } + stateDetails.push(errorMessage); this.logger.warn(errorMessage, error); } finally { diff --git a/src/lib/addons/slack.test.ts b/src/lib/addons/slack.test.ts index 8e67cb3adf..eba87005c7 100644 --- a/src/lib/addons/slack.test.ts +++ b/src/lib/addons/slack.test.ts @@ -11,6 +11,7 @@ import SlackAddon from './slack'; import noLogger from '../../test/fixtures/no-logger'; import { type IAddonConfig, + type IFlagKey, type IFlagResolver, serializeDates, SYSTEM_USER_ID, @@ -25,7 +26,8 @@ const ARGS: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; jest.mock( diff --git a/src/lib/addons/slack.ts b/src/lib/addons/slack.ts index ce0cad61bd..0d67576e16 100644 --- a/src/lib/addons/slack.ts +++ b/src/lib/addons/slack.ts @@ -1,7 +1,11 @@ import Addon from './addon'; import slackDefinition from './slack-definition'; -import { type IAddonConfig, serializeDates } from '../types'; +import { + type IAddonConfig, + type IFlagResolver, + serializeDates, +} from '../types'; import { type FeatureEventFormatter, @@ -10,6 +14,7 @@ import { } from './feature-event-formatter-md'; import type { IEvent } from '../types/events'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; interface ISlackAddonParameters { url: string; @@ -21,12 +26,15 @@ interface ISlackAddonParameters { export default class SlackAddon extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + constructor(args: IAddonConfig) { super(slackDefinition, args); this.msgFormatter = new FeatureEventFormatterMd( args.unleashUrl, LinkStyle.SLACK, ); + this.flagResolver = args.flagResolver; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -121,6 +129,13 @@ export default class SlackAddon extends Addon { state = 'successWithErrors'; const successWithErrorsMessage = `Some (${failedRequests.length} of ${results.length}) Slack webhook requests failed. Status codes: ${codes}.`; stateDetails.push(successWithErrorsMessage); + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'slack', + }); + } + this.logger.warn(successWithErrorsMessage); } diff --git a/src/lib/addons/teams.test.ts b/src/lib/addons/teams.test.ts index dcf7a38caa..8c1b14c0bb 100644 --- a/src/lib/addons/teams.test.ts +++ b/src/lib/addons/teams.test.ts @@ -12,6 +12,7 @@ import TeamsAddon from './teams'; import noLogger from '../../test/fixtures/no-logger'; import { type IAddonConfig, + type IFlagKey, type IFlagResolver, serializeDates, SYSTEM_USER_ID, @@ -26,7 +27,8 @@ const ARGS: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; jest.mock( diff --git a/src/lib/addons/teams.ts b/src/lib/addons/teams.ts index c1492d4e1f..1366b49876 100644 --- a/src/lib/addons/teams.ts +++ b/src/lib/addons/teams.ts @@ -1,13 +1,18 @@ import Addon from './addon'; import teamsDefinition from './teams-definition'; -import { type IAddonConfig, serializeDates } from '../types'; +import { + type IAddonConfig, + type IFlagResolver, + serializeDates, +} from '../types'; import { type FeatureEventFormatter, FeatureEventFormatterMd, } from './feature-event-formatter-md'; import type { IEvent } from '../types/events'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; interface ITeamsParameters { url: string; @@ -16,9 +21,12 @@ interface ITeamsParameters { export default class TeamsAddon extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + constructor(args: IAddonConfig) { super(teamsDefinition, args); this.msgFormatter = new FeatureEventFormatterMd(args.unleashUrl); + this.flagResolver = args.flagResolver; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -97,6 +105,13 @@ export default class TeamsAddon extends Addon { state = 'failed'; const failedMessage = `Teams webhook request failed with status code: ${res.status}.`; stateDetails.push(failedMessage); + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'teams', + }); + } + this.logger.warn(failedMessage); } diff --git a/src/lib/addons/webhook.test.ts b/src/lib/addons/webhook.test.ts index fa1a28e30f..eb059e5ddf 100644 --- a/src/lib/addons/webhook.test.ts +++ b/src/lib/addons/webhook.test.ts @@ -7,6 +7,7 @@ import WebhookAddon from './webhook'; import noLogger from '../../test/fixtures/no-logger'; import { type IAddonConfig, + type IFlagKey, type IFlagResolver, serializeDates, SYSTEM_USER_ID, @@ -21,7 +22,8 @@ const ARGS: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, - flagResolver: {} as IFlagResolver, + flagResolver: { isEnabled: (expName: IFlagKey) => false } as IFlagResolver, + eventBus: {} as any, }; jest.mock( diff --git a/src/lib/addons/webhook.ts b/src/lib/addons/webhook.ts index 5862a16d34..e201523f26 100644 --- a/src/lib/addons/webhook.ts +++ b/src/lib/addons/webhook.ts @@ -2,16 +2,22 @@ import Mustache from 'mustache'; import Addon from './addon'; import definition from './webhook-definition'; import type { IEvent } from '../types/events'; -import { type IAddonConfig, serializeDates } from '../types'; +import { + type IAddonConfig, + type IFlagResolver, + serializeDates, +} from '../types'; import type { IntegrationEventState } from '../features/integration-events/integration-events-store'; import { type FeatureEventFormatter, FeatureEventFormatterMd, LinkStyle, } from './feature-event-formatter-md'; +import { ADDON_EVENTS_HANDLED } from '../metric-events'; interface IParameters { url: string; + serviceName?: string; bodyTemplate?: string; contentType?: string; authorization?: string; @@ -21,12 +27,15 @@ interface IParameters { export default class Webhook extends Addon { private msgFormatter: FeatureEventFormatter; + flagResolver: IFlagResolver; + constructor(args: IAddonConfig) { super(definition, args); this.msgFormatter = new FeatureEventFormatterMd( args.unleashUrl, LinkStyle.MD, ); + this.flagResolver = args.flagResolver; } async handleEvent( @@ -94,6 +103,13 @@ export default class Webhook extends Addon { state = 'failed'; const failedMessage = `Webhook request failed with status code: ${res.status}.`; stateDetails.push(failedMessage); + if (this.flagResolver.isEnabled('addonUsageMetrics')) { + this.eventBus.emit(ADDON_EVENTS_HANDLED, { + result: state, + destination: 'webhook', + }); + } + this.logger.warn(failedMessage); } diff --git a/src/lib/metric-events.ts b/src/lib/metric-events.ts index f989e5980a..5dd781b402 100644 --- a/src/lib/metric-events.ts +++ b/src/lib/metric-events.ts @@ -12,6 +12,7 @@ const PROXY_FEATURES_FOR_TOKEN_TIME = 'proxy_features_for_token_time'; const STAGE_ENTERED = 'stage-entered' as const; const EXCEEDS_LIMIT = 'exceeds-limit' as const; const REQUEST_ORIGIN = 'request_origin' as const; +const ADDON_EVENTS_HANDLED = 'addon-event-handled' as const; type MetricEvent = | typeof REQUEST_TIME @@ -71,6 +72,7 @@ export { STAGE_ENTERED, EXCEEDS_LIMIT, REQUEST_ORIGIN, + ADDON_EVENTS_HANDLED, type MetricEvent, type MetricEventPayload, emitMetricEvent, diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index d958d071ef..44cbf51b18 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -359,6 +359,12 @@ export default class MetricsMonitor { labelNames: ['resource'], }); + const addonEventsHandledCounter = createCounter({ + name: 'addon_events_handled', + help: 'Events handled by addons and the result.', + labelNames: ['result', 'destination'], + }); + async function collectStaticCounters() { try { const stats = await instanceStatsService.getStats(); @@ -906,6 +912,10 @@ export default class MetricsMonitor { projectEnvironmentsDisabled.increment({ project_id: project }); }); + eventBus.on(events.ADDON_EVENTS_HANDLED, ({ result, destination }) => { + addonEventsHandledCounter.increment({ result, destination }); + }); + await this.configureDbMetrics( db, eventBus, diff --git a/src/lib/services/addon-service-test-simple-addon.ts b/src/lib/services/addon-service-test-simple-addon.ts index d159db68a1..3968436175 100644 --- a/src/lib/services/addon-service-test-simple-addon.ts +++ b/src/lib/services/addon-service-test-simple-addon.ts @@ -15,6 +15,7 @@ const ARGS: IAddonConfig = { unleashUrl: 'http://some-url.com', integrationEventsService: {} as IntegrationEventsService, flagResolver: {} as IFlagResolver, + eventBus: {} as any, }; const definition: IAddonDefinition = { diff --git a/src/lib/services/addon-service.ts b/src/lib/services/addon-service.ts index 76c1f1b37c..3bf4542e2c 100644 --- a/src/lib/services/addon-service.ts +++ b/src/lib/services/addon-service.ts @@ -64,7 +64,11 @@ export default class AddonService { getLogger, server, flagResolver, - }: Pick, + eventBus, + }: Pick< + IUnleashConfig, + 'getLogger' | 'server' | 'flagResolver' | 'eventBus' + >, tagTypeService: TagTypeService, eventService: EventService, integrationEventsService, @@ -83,6 +87,7 @@ export default class AddonService { unleashUrl: server.unleashUrl, integrationEventsService, flagResolver, + eventBus, }); this.sensitiveParams = this.loadSensitiveParams(this.addonProviders); if (addonStore) { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 19d3a27bbb..85e589550c 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -10,6 +10,7 @@ import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-sea import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; import type { IFlagResolver } from './experimental'; import type { Collaborator } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; +import type { EventEmitter } from 'events'; export type Operator = (typeof ALL_OPERATORS)[number]; @@ -384,6 +385,7 @@ export interface IAddonConfig { unleashUrl: string; integrationEventsService: IntegrationEventsService; flagResolver: IFlagResolver; + eventBus: EventEmitter; } export interface IUserWithRole {