From d8478dd9287e7ab7f7ff5f794a78372f9366e07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Fri, 12 Nov 2021 13:15:51 +0100 Subject: [PATCH] feat: clean up events (#1089) Co-authored-by: Christopher Kolstad --- docs/api/oas/openapi.yaml | 90 ++-- package.json | 1 - src/lib/addons/addon.ts | 3 +- src/lib/addons/datadog.test.ts | 5 +- src/lib/addons/datadog.ts | 3 +- src/lib/addons/feature-event-formatter-md.ts | 31 +- src/lib/addons/slack.test.ts | 8 +- src/lib/addons/slack.ts | 3 +- src/lib/addons/teams.test.ts | 5 +- src/lib/addons/teams.ts | 3 +- src/lib/addons/webhook.test.ts | 5 +- src/lib/addons/webhook.ts | 11 +- src/lib/db/event-store.ts | 38 +- src/lib/event-differ.js | 171 -------- src/lib/event-differ.test.js | 158 ------- src/lib/metrics.test.ts | 1 + src/lib/metrics.ts | 28 +- src/lib/routes/admin-api/archive.ts | 4 +- src/lib/routes/admin-api/event.ts | 32 +- src/lib/routes/admin-api/feature.ts | 4 +- src/lib/routes/admin-api/project/features.ts | 4 +- src/lib/routes/admin-api/state.ts | 1 - src/lib/routes/client-api/feature.ts | 4 +- .../addon-service-test-simple-addon.ts | 3 +- src/lib/services/client-metrics/index.ts | 11 +- src/lib/services/event-service.ts | 4 +- src/lib/services/feature-tag-service.ts | 18 +- src/lib/services/feature-toggle-service.ts | 399 ++++++++++-------- src/lib/services/project-health-service.ts | 6 +- src/lib/services/project-service.ts | 14 +- src/lib/services/user-service.ts | 78 ++-- src/lib/types/events.ts | 302 ++++++++++++- src/lib/types/model.ts | 14 - src/lib/types/stores/event-store.ts | 7 +- ...04316-add-feature-name-column-to-events.js | 23 + ...1105105509-add-predata-column-to-events.js | 9 + .../api/admin/project/features.e2e.test.ts | 14 +- src/test/e2e/api/admin/user-admin.e2e.test.ts | 31 +- .../feature-toggle-service-v2.e2e.test.ts | 6 +- .../project-health-service.e2e.test.ts | 4 +- .../e2e/services/project-service.e2e.test.ts | 6 +- src/test/e2e/stores/event-store.e2e.test.ts | 2 +- src/test/fixtures/fake-event-store.ts | 6 +- website/docs/addons/datadog.md | 13 +- website/docs/addons/slack.md | 13 +- website/docs/addons/teams.md | 13 +- website/docs/addons/webhook.md | 11 +- website/docs/api/admin/events-api.md | 207 ++++++++- yarn.lock | 5 - 49 files changed, 1032 insertions(+), 800 deletions(-) delete mode 100644 src/lib/event-differ.js delete mode 100644 src/lib/event-differ.test.js create mode 100644 src/migrations/20211105104316-add-feature-name-column-to-events.js create mode 100644 src/migrations/20211105105509-add-predata-column-to-events.js diff --git a/docs/api/oas/openapi.yaml b/docs/api/oas/openapi.yaml index 5a8315c794..9626cced61 100644 --- a/docs/api/oas/openapi.yaml +++ b/docs/api/oas/openapi.yaml @@ -660,13 +660,19 @@ paths: operationId: get-admin-events summary: Fetch all changes in the Unleash system description: |- - Returns one of the six event types: + Returns one of the twelve event types: - feature-created - - feature-updated + - feature-metadata-updated + - feature-project-change - feature-archived - feature-revived - - strategy-created - - strategy-deleted + - feature-strategy-update + - feature-strategy-add + - feature-strategy-remove + - feature-stale-on + - feature-stale-off + - feature-environment-enabled + - feature-environment-disabled tags: - Events responses: @@ -1548,15 +1554,21 @@ components: type: number example: 55 type: - description: One of the six event types + description: Identifies the event type: string enum: - feature-created - - feature-updated + - feature-metadata-updated + - feature-project-change - feature-archived - feature-revived - - strategy-created - - strategy-deleted + - feature-strategy-update + - feature-strategy-add + - feature-strategy-remove + - feature-stale-on + - feature-stale-off + - feature-environment-enabled + - feature-environment-disabled minLength: 1 example: feature-updated createdBy: @@ -1568,53 +1580,21 @@ components: type: string example: '2016-12-09T14:56:36.730Z' data: - $ref: '#/components/schemas/featureToggleSchema' - diffs: - description: |- - The JSON differences between the current and last version of the Feature Toggle. - (Uses the [deep-diff Node.js module](https://www.npmjs.com/package/deep-diff)) - externalDocs: - description: Activation strategies - url: 'https://www.npmjs.com/package/deep-diff#differences' - type: array - items: - required: - - kind - - lhs - - rhs - properties: - kind: - description: |- - The kind of change: - - **N** - a newly-added property or element - - **D** - a property or element was deleted - - **E** - a property or element was edited - - **A** - a change occurred within an array - type: string - enum: - - 'N' - - D - - E - - A - example: E - path: - type: array - items: - required: - - pathItem - properties: - pathItem: - description: The property path (from the left-hand-side root) - type: string - example: enabled - lhs: - description: The value on the left-hand-side of the comparison (*undefined* if **kind** is *N*) - type: boolean - example: true - rhs: - description: The value on the right-hand-side of the comparison (*undefined* if **kind** is *D*) - type: boolean - example: false + description: The current state of the updated resource + type: object + preData: + description: The previous state of the updated resource + type: object + featureName: + description: Name of the feature toggle (if event related to a feature toggle) + type: string + project: + description: Name of the project (if event related to a project resource) + type: string + environment: + description: Name of the environment (if event related to a environment resource) + type: string + x-tags: - Responses 200export: diff --git a/package.json b/package.json index 68db2010b7..52c886eb62 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "db-migrate": "0.11.12", "db-migrate-pg": "1.2.2", "db-migrate-shared": "1.2.0", - "deep-diff": "^1.0.2", "deepmerge": "^4.2.2", "errorhandler": "^1.5.1", "express": "^4.17.1", diff --git a/src/lib/addons/addon.ts b/src/lib/addons/addon.ts index 124af0fbb2..ea40ea218e 100644 --- a/src/lib/addons/addon.ts +++ b/src/lib/addons/addon.ts @@ -2,7 +2,8 @@ import fetch, { Response } from 'node-fetch'; import { addonDefinitionSchema } from './addon-schema'; import { IUnleashConfig } from '../types/option'; import { Logger } from '../logger'; -import { IAddonDefinition, IEvent } from '../types/model'; +import { IAddonDefinition } from '../types/model'; +import { IEvent } from '../types/events'; export default abstract class Addon { logger: Logger; diff --git a/src/lib/addons/datadog.test.ts b/src/lib/addons/datadog.test.ts index 3b221728d1..50b8ca4d29 100644 --- a/src/lib/addons/datadog.test.ts +++ b/src/lib/addons/datadog.test.ts @@ -2,13 +2,13 @@ import { FEATURE_ARCHIVED, FEATURE_CREATED, FEATURE_ENVIRONMENT_DISABLED, + IEvent, } from '../types/events'; import { Logger } from '../logger'; import DatadogAddon from './datadog'; import noLogger from '../../test/fixtures/no-logger'; -import { IEvent } from '../types/model'; let fetchRetryCalls: any[] = []; @@ -45,6 +45,7 @@ test('Should call datadog webhook', async () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -72,6 +73,7 @@ test('Should call datadog webhook for archived toggle', async () => { createdAt: new Date(), type: FEATURE_ARCHIVED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', }, @@ -99,6 +101,7 @@ test(`Should call datadog webhook for toggled environment`, async () => { createdBy: 'some@user.com', environment: 'development', project: 'default', + featureName: 'some-toggle', data: { name: 'some-toggle', }, diff --git a/src/lib/addons/datadog.ts b/src/lib/addons/datadog.ts index dcedd36edd..87a656db4a 100644 --- a/src/lib/addons/datadog.ts +++ b/src/lib/addons/datadog.ts @@ -1,12 +1,13 @@ import Addon from './addon'; import definition from './datadog-definition'; -import { IAddonConfig, IEvent } from '../types/model'; +import { IAddonConfig } from '../types/model'; import { FeatureEventFormatter, FeatureEventFormatterMd, LinkStyle, } from './feature-event-formatter-md'; +import { IEvent } from '../types/events'; export default class DatadogAddon extends Addon { private msgFormatter: FeatureEventFormatter; diff --git a/src/lib/addons/feature-event-formatter-md.ts b/src/lib/addons/feature-event-formatter-md.ts index 83291827f2..d65c21fe9b 100644 --- a/src/lib/addons/feature-event-formatter-md.ts +++ b/src/lib/addons/feature-event-formatter-md.ts @@ -1,4 +1,3 @@ -import { IEvent } from '../types/model'; import { FEATURE_CREATED, FEATURE_UPDATED, @@ -13,6 +12,7 @@ import { FEATURE_STRATEGY_REMOVE, FEATURE_METADATA_UPDATED, FEATURE_PROJECT_CHANGE, + IEvent, } from '../types/events'; export interface FeatureEventFormatter { @@ -44,9 +44,9 @@ export class FeatureEventFormatterMd implements FeatureEventFormatter { generateFeatureLink(event: IEvent): string { if (this.linkStyle === LinkStyle.SLACK) { - return `<${this.featureLink(event)}|${event.data.name}>`; + return `<${this.featureLink(event)}|${event.featureName}>`; } else { - return `[${event.data.name}](${this.featureLink(event)})`; + return `[${event.featureName}](${this.featureLink(event)})`; } } @@ -70,20 +70,17 @@ export class FeatureEventFormatterMd implements FeatureEventFormatter { } generateStrategyChangeText(event: IEvent): string { - const { createdBy, environment, project, data, type } = event; + const { createdBy, environment, project, data, preData, type } = event; const feature = this.generateFeatureLink(event); - let action; + let strategyText: string = ''; if (FEATURE_STRATEGY_UPDATE === type) { - action = 'updated in'; + strategyText = `by updating strategy ${data?.name} in *${environment}*`; } else if (FEATURE_STRATEGY_ADD === type) { - action = 'added to'; - } else { - action = 'removed from'; + strategyText = `by adding strategy ${data?.name} in *${environment}*`; + } else if (FEATURE_STRATEGY_REMOVE === type) { + strategyText = `by removing strategy ${preData?.name} in *${environment}*`; } - const strategyText = `a ${ - data.strategyName ?? '' - } strategy ${action} the *${environment}* environment`; - return `${createdBy} updated *${feature}* with ${strategyText} in project *${project}*`; + return `${createdBy} updated *${feature}* in project *${project}* ${strategyText}`; } generateMetadataText(event: IEvent): string { @@ -93,16 +90,16 @@ export class FeatureEventFormatterMd implements FeatureEventFormatter { } generateProjectChangeText(event: IEvent): string { - const { createdBy, project, data } = event; - return `${createdBy} moved ${data.name} to ${project}`; + const { createdBy, project, featureName } = event; + return `${createdBy} moved ${featureName} to ${project}`; } featureLink(event: IEvent): string { - const { type, project = '', data } = event; + const { type, project = '', featureName } = event; if (type === FEATURE_ARCHIVED) { return `${this.unleashUrl}/archive`; } - return `${this.unleashUrl}/projects/${project}/${data.name}`; + return `${this.unleashUrl}/projects/${project}/${featureName}`; } getAction(type: string): string { diff --git a/src/lib/addons/slack.test.ts b/src/lib/addons/slack.test.ts index 7cf75201dc..239639b127 100644 --- a/src/lib/addons/slack.test.ts +++ b/src/lib/addons/slack.test.ts @@ -2,13 +2,13 @@ import { FEATURE_CREATED, FEATURE_ARCHIVED, FEATURE_ENVIRONMENT_DISABLED, + IEvent, } from '../types/events'; import { Logger } from '../logger'; import SlackAddon from './slack'; import noLogger from '../../test/fixtures/no-logger'; -import { IEvent } from '../types/model'; let fetchRetryCalls: any[] = []; @@ -46,6 +46,7 @@ test('Should call slack webhook', async () => { type: FEATURE_CREATED, createdBy: 'some@user.com', project: 'default', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -73,6 +74,7 @@ test('Should call slack webhook for archived toggle', async () => { id: 2, createdAt: new Date(), type: FEATURE_ARCHIVED, + featureName: 'some-toggle', createdBy: 'some@user.com', data: { name: 'some-toggle', @@ -101,6 +103,7 @@ test(`Should call webhook for toggled environment`, async () => { createdBy: 'some@user.com', environment: 'development', project: 'default', + featureName: 'some-toggle', data: { name: 'some-toggle', }, @@ -127,6 +130,7 @@ test('Should use default channel', async () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -156,6 +160,7 @@ test('Should override default channel with data from tag', async () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -191,6 +196,7 @@ test('Should post to all channels in tags', async () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, diff --git a/src/lib/addons/slack.ts b/src/lib/addons/slack.ts index 590e560b51..45795d2548 100644 --- a/src/lib/addons/slack.ts +++ b/src/lib/addons/slack.ts @@ -1,13 +1,14 @@ import Addon from './addon'; import slackDefinition from './slack-definition'; -import { IAddonConfig, IEvent } from '../types/model'; +import { IAddonConfig } from '../types/model'; import { FeatureEventFormatter, FeatureEventFormatterMd, LinkStyle, } from './feature-event-formatter-md'; +import { IEvent } from '../types/events'; export default class SlackAddon extends Addon { private msgFormatter: FeatureEventFormatter; diff --git a/src/lib/addons/teams.test.ts b/src/lib/addons/teams.test.ts index ec7355b0c7..06a9df8583 100644 --- a/src/lib/addons/teams.test.ts +++ b/src/lib/addons/teams.test.ts @@ -4,12 +4,12 @@ import { FEATURE_ARCHIVED, FEATURE_CREATED, FEATURE_ENVIRONMENT_DISABLED, + IEvent, } from '../types/events'; import TeamsAddon from './teams'; import noLogger from '../../test/fixtures/no-logger'; -import { IEvent } from '../types/model'; let fetchRetryCalls: any[]; @@ -46,6 +46,7 @@ test('Should call teams webhook', async () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -73,6 +74,7 @@ test('Should call teams webhook for archived toggle', async () => { createdAt: new Date(), type: FEATURE_ARCHIVED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', }, @@ -100,6 +102,7 @@ test(`Should call teams webhook for toggled environment`, async () => { createdBy: 'some@user.com', environment: 'development', project: 'default', + featureName: 'some-toggle', data: { name: 'some-toggle', }, diff --git a/src/lib/addons/teams.ts b/src/lib/addons/teams.ts index dbb4f8841b..c1a2aad781 100644 --- a/src/lib/addons/teams.ts +++ b/src/lib/addons/teams.ts @@ -1,11 +1,12 @@ import Addon from './addon'; import teamsDefinition from './teams-definition'; -import { IAddonConfig, IEvent } from '../types/model'; +import { IAddonConfig } from '../types/model'; import { FeatureEventFormatter, FeatureEventFormatterMd, } from './feature-event-formatter-md'; +import { IEvent } from '../types/events'; export default class TeamsAddon extends Addon { private msgFormatter: FeatureEventFormatter; diff --git a/src/lib/addons/webhook.test.ts b/src/lib/addons/webhook.test.ts index f81ff435b3..3e21b40140 100644 --- a/src/lib/addons/webhook.test.ts +++ b/src/lib/addons/webhook.test.ts @@ -1,11 +1,10 @@ import { Logger } from '../logger'; -import { FEATURE_CREATED } from '../types/events'; +import { FEATURE_CREATED, IEvent } from '../types/events'; import WebhookAddon from './webhook'; import noLogger from '../../test/fixtures/no-logger'; -import { IEvent } from '../types/model'; let fetchRetryCalls: any[] = []; @@ -39,6 +38,7 @@ test('Should handle event without "bodyTemplate"', () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, @@ -63,6 +63,7 @@ test('Should format event with "bodyTemplate"', () => { createdAt: new Date(), type: FEATURE_CREATED, createdBy: 'some@user.com', + featureName: 'some-toggle', data: { name: 'some-toggle', enabled: false, diff --git a/src/lib/addons/webhook.ts b/src/lib/addons/webhook.ts index 22c4874c26..dda4d8e8dc 100644 --- a/src/lib/addons/webhook.ts +++ b/src/lib/addons/webhook.ts @@ -2,15 +2,20 @@ import Mustache from 'mustache'; import Addon from './addon'; import definition from './webhook-definition'; import { LogProvider } from '../logger'; -import { IEvent } from '../types/model'; +import { IEvent } from '../types/events'; + +interface IParameters { + url: string; + bodyTemplate?: string; + contentType?: string; +} export default class Webhook extends Addon { constructor(args: { getLogger: LogProvider }) { super(definition, args); } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async handleEvent(event: IEvent, parameters: any): Promise { + async handleEvent(event: IEvent, parameters: IParameters): Promise { const { url, bodyTemplate, contentType } = parameters; const context = { event, diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 1911ea8687..6bd13fde56 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -1,9 +1,9 @@ import { EventEmitter } from 'events'; import { Knex } from 'knex'; -import { DROP_FEATURES } from '../types/events'; +import { DROP_FEATURES, IEvent, IBaseEvent } from '../types/events'; import { LogProvider, Logger } from '../logger'; import { IEventStore } from '../types/stores/event-store'; -import { ICreateEvent, IEvent } from '../types/model'; +import { ITag } from '../types/model'; const EVENT_COLUMNS = [ 'id', @@ -11,7 +11,9 @@ const EVENT_COLUMNS = [ 'created_by', 'created_at', 'data', + 'pre_data', 'tags', + 'feature_name', 'project', 'environment', ]; @@ -21,10 +23,12 @@ export interface IEventTable { type: string; created_by: string; created_at: Date; - data: any; + data?: any; + pre_data?: any; + feature_name?: string; project?: string; environment?: string; - tags: []; + tags: ITag[]; } const TABLE = 'events'; @@ -40,7 +44,7 @@ class EventStore extends EventEmitter implements IEventStore { this.logger = getLogger('lib/db/event-store.ts'); } - async store(event: ICreateEvent): Promise { + async store(event: IBaseEvent): Promise { try { const rows = await this.db(TABLE) .insert(this.eventToDbRow(event)) @@ -52,7 +56,7 @@ class EventStore extends EventEmitter implements IEventStore { } } - async batchStore(events: ICreateEvent[]): Promise { + async batchStore(events: IBaseEvent[]): Promise { try { const savedRows = await this.db(TABLE) .insert(events.map(this.eventToDbRow)) @@ -129,6 +133,7 @@ class EventStore extends EventEmitter implements IEventStore { .orderBy('created_at', 'desc'); return rows.map(this.rowToEvent); } catch (err) { + this.logger.error(err); return []; } } @@ -146,6 +151,19 @@ class EventStore extends EventEmitter implements IEventStore { } } + async getEventsForFeature(featureName: string): Promise { + try { + const rows = await this.db + .select(EVENT_COLUMNS) + .from(TABLE) + .where({ feature_name: featureName }) + .orderBy('created_at', 'desc'); + return rows.map(this.rowToEvent); + } catch (err) { + return []; + } + } + rowToEvent(row: IEventTable): IEvent { return { id: row.id, @@ -153,23 +171,27 @@ class EventStore extends EventEmitter implements IEventStore { createdBy: row.created_by, createdAt: row.created_at, data: row.data, + preData: row.pre_data, tags: row.tags || [], + featureName: row.feature_name, project: row.project, environment: row.environment, }; } - eventToDbRow(e: ICreateEvent): any { + eventToDbRow(e: IBaseEvent): Omit { return { type: e.type, created_by: e.createdBy, data: e.data, + pre_data: e.preData, + //@ts-ignore workaround for json-array tags: JSON.stringify(e.tags), + feature_name: e.featureName, project: e.project, environment: e.environment, }; } } -module.exports = EventStore; export default EventStore; diff --git a/src/lib/event-differ.js b/src/lib/event-differ.js deleted file mode 100644 index 34ceb6c122..0000000000 --- a/src/lib/event-differ.js +++ /dev/null @@ -1,171 +0,0 @@ -/* eslint-disable no-param-reassign */ - -'use strict'; - -const { diff } = require('deep-diff'); -const { - STRATEGY_CREATED, - STRATEGY_DELETED, - STRATEGY_UPDATED, - STRATEGY_IMPORT, - STRATEGY_DEPRECATED, - STRATEGY_REACTIVATED, - DROP_STRATEGIES, - FEATURE_CREATED, - FEATURE_UPDATED, - FEATURE_ARCHIVED, - FEATURE_REVIVED, - FEATURE_IMPORT, - FEATURE_TAGGED, - FEATURE_UNTAGGED, - DROP_FEATURES, - CONTEXT_FIELD_CREATED, - CONTEXT_FIELD_UPDATED, - CONTEXT_FIELD_DELETED, - PROJECT_CREATED, - PROJECT_UPDATED, - PROJECT_DELETED, - TAG_CREATED, - TAG_DELETED, - TAG_TYPE_CREATED, - TAG_TYPE_DELETED, - APPLICATION_CREATED, - FEATURE_STALE_ON, - FEATURE_STALE_OFF, - USER_CREATED, - USER_UPDATED, - USER_DELETED, -} = require('./types/events'); - -const strategyTypes = [ - STRATEGY_CREATED, - STRATEGY_DELETED, - STRATEGY_UPDATED, - STRATEGY_IMPORT, - STRATEGY_DEPRECATED, - STRATEGY_REACTIVATED, - DROP_STRATEGIES, -]; - -const featureTypes = [ - FEATURE_CREATED, - FEATURE_UPDATED, - FEATURE_ARCHIVED, - FEATURE_REVIVED, - FEATURE_IMPORT, - FEATURE_TAGGED, - FEATURE_UNTAGGED, - DROP_FEATURES, - FEATURE_STALE_ON, - FEATURE_STALE_OFF, -]; - -const contextTypes = [ - CONTEXT_FIELD_CREATED, - CONTEXT_FIELD_DELETED, - CONTEXT_FIELD_UPDATED, -]; - -const userTypes = [USER_CREATED, USER_UPDATED, USER_DELETED]; - -const tagTypes = [TAG_CREATED, TAG_DELETED]; - -const tagTypeTypes = [TAG_TYPE_CREATED, TAG_TYPE_DELETED]; - -const projectTypes = [PROJECT_CREATED, PROJECT_UPDATED, PROJECT_DELETED]; - -function baseTypeFor(event) { - if (featureTypes.indexOf(event.type) !== -1) { - return 'features'; - } - if (strategyTypes.indexOf(event.type) !== -1) { - return 'strategies'; - } - if (contextTypes.indexOf(event.type) !== -1) { - return 'context'; - } - if (projectTypes.indexOf(event.type) !== -1) { - return 'project'; - } - if (tagTypes.indexOf(event.type) !== -1) { - return 'tag'; - } - if (tagTypeTypes.indexOf(event.type) !== -1) { - return 'tag-type'; - } - if (userTypes.indexOf(event.type) !== -1) { - return 'user'; - } - if (event.type === APPLICATION_CREATED) { - return 'application'; - } - return event.type; -} - -const uniqueFieldForType = (baseType) => { - if (baseType === 'user') { - return 'id'; - } - return 'name'; -}; - -function groupByBaseTypeAndName(events) { - const groups = {}; - - events.forEach((event) => { - const baseType = baseTypeFor(event); - const uniqueField = uniqueFieldForType(baseType); - - groups[baseType] = groups[baseType] || {}; - groups[baseType][event.data[uniqueField]] = - groups[baseType][event.data[uniqueField]] || []; - - groups[baseType][event.data[uniqueField]].push(event); - }); - - return groups; -} - -function eachConsecutiveEvent(events, callback) { - const groups = groupByBaseTypeAndName(events); - - Object.keys(groups).forEach((baseType) => { - const group = groups[baseType]; - - Object.keys(group).forEach((name) => { - const currentEvents = group[name]; - let left; - let right; - let i; - let l; - for (i = 0, l = currentEvents.length; i < l; i++) { - left = currentEvents[i]; - right = currentEvents[i + 1]; - - callback(left, right); - } - }); - }); -} - -const ignoredProps = ['createdAt', 'lastSeenAt', 'id']; - -const filterProps = (path, key) => { - return ignoredProps.includes(key); -}; - -function addDiffs(events = []) { - // TODO: no-param-reassign - eachConsecutiveEvent(events, (left, right) => { - if (right) { - left.diffs = diff(right.data, left.data, filterProps); - left.diffs = left.diffs || []; - } else { - left.diffs = null; - } - }); -} - -module.exports = { - addDiffs, -}; diff --git a/src/lib/event-differ.test.js b/src/lib/event-differ.test.js deleted file mode 100644 index 4c13dd740f..0000000000 --- a/src/lib/event-differ.test.js +++ /dev/null @@ -1,158 +0,0 @@ -'use strict'; - -const eventDiffer = require('./event-differ'); -const { FEATURE_CREATED, FEATURE_UPDATED } = require('./types/events'); - -test('should not fail if events include an unknown event type', () => { - const events = [ - { type: FEATURE_CREATED, data: {} }, - { type: 'unknown-type', data: {} }, - ]; - - eventDiffer.addDiffs(events); - - expect(true).toBe(true); -}); - -test('diffs a feature-update event', () => { - const feature = 'foo'; - const desc = 'bar'; - - const events = [ - { - type: FEATURE_UPDATED, - data: { - name: feature, - description: desc, - strategy: 'default', - enabled: true, - parameters: { value: 2 }, - }, - }, - { - type: FEATURE_CREATED, - data: { - name: feature, - description: desc, - strategy: 'default', - enabled: false, - parameters: { value: 1 }, - }, - }, - ]; - - eventDiffer.addDiffs(events); - - const { diffs } = events[0]; - expect(diffs[0].kind === 'E').toBe(true); - expect(diffs[0].path[0] === 'enabled').toBe(true); - expect(diffs[0].kind === 'E').toBe(true); - expect(diffs[0].lhs === false).toBe(true); - expect(diffs[0].rhs).toBe(true); - - expect(diffs[1].kind === 'E').toBe(true); - expect(diffs[1].path[0] === 'parameters').toBe(true); - expect(diffs[1].path[1] === 'value').toBe(true); - expect(diffs[1].kind === 'E').toBe(true); - expect(diffs[1].lhs === 1).toBe(true); - - expect(events[1].diffs === null).toBe(true); -}); - -test('diffs only against features with the same name', () => { - const events = [ - { - type: FEATURE_UPDATED, - data: { - name: 'bar', - description: 'desc', - strategy: 'default', - enabled: true, - parameters: {}, - }, - }, - { - type: FEATURE_UPDATED, - data: { - name: 'foo', - description: 'desc', - strategy: 'default', - enabled: false, - parameters: {}, - }, - }, - { - type: FEATURE_CREATED, - data: { - name: 'bar', - description: 'desc', - strategy: 'default', - enabled: false, - parameters: {}, - }, - }, - { - type: FEATURE_CREATED, - data: { - name: 'foo', - description: 'desc', - strategy: 'default', - enabled: true, - parameters: {}, - }, - }, - ]; - - eventDiffer.addDiffs(events); - - expect(events[0].diffs[0].rhs === true).toBe(true); - expect(events[1].diffs[0].rhs === false).toBe(true); - expect(events[2].diffs === null).toBe(true); - expect(events[3].diffs === null).toBe(true); -}); - -test('sets an empty array of diffs if nothing was changed', () => { - const events = [ - { - type: FEATURE_UPDATED, - data: { - name: 'foo', - description: 'desc', - strategy: 'default', - enabled: true, - parameters: {}, - }, - }, - { - type: FEATURE_CREATED, - data: { - name: 'foo', - description: 'desc', - strategy: 'default', - enabled: true, - parameters: {}, - }, - }, - ]; - - eventDiffer.addDiffs(events); - expect(events[0].diffs).toEqual([]); -}); - -test('sets diffs to null if there was nothing to diff against', () => { - const events = [ - { - type: FEATURE_UPDATED, - data: { - name: 'foo', - description: 'desc', - strategy: 'default', - enabled: true, - parameters: {}, - }, - }, - ]; - - eventDiffer.addDiffs(events); - expect(events[0].diffs === null).toBe(true); -}); diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index 3ce2d1895c..9d4921a425 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -53,6 +53,7 @@ test('should collect metrics for requests', async () => { test('should collect metrics for updated toggles', async () => { stores.eventStore.emit(FEATURE_UPDATED, { + featureName: 'TestToggle', data: { name: 'TestToggle' }, }); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index f0251881b1..7467700ec0 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -7,6 +7,9 @@ import { FEATURE_ARCHIVED, FEATURE_CREATED, FEATURE_REVIVED, + FEATURE_STRATEGY_ADD, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, FEATURE_UPDATED, } from './types/events'; import { IUnleashConfig } from './types/option'; @@ -123,17 +126,26 @@ export default class MetricsMonitor { dbDuration.labels(store, action).observe(time); }); - eventStore.on(FEATURE_CREATED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); + eventStore.on(FEATURE_CREATED, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); }); - eventStore.on(FEATURE_UPDATED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); + eventStore.on(FEATURE_UPDATED, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); }); - eventStore.on(FEATURE_ARCHIVED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); + eventStore.on(FEATURE_STRATEGY_ADD, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); }); - eventStore.on(FEATURE_REVIVED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); + eventStore.on(FEATURE_STRATEGY_REMOVE, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); + }); + eventStore.on(FEATURE_STRATEGY_UPDATE, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); + }); + eventStore.on(FEATURE_ARCHIVED, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); + }); + eventStore.on(FEATURE_REVIVED, ({ featureName }) => { + featureToggleUpdateTotal.labels(featureName).inc(); }); clientMetricsStore.on('metrics', (m) => { diff --git a/src/lib/routes/admin-api/archive.ts b/src/lib/routes/admin-api/archive.ts index 96d2cb1d72..a087620601 100644 --- a/src/lib/routes/admin-api/archive.ts +++ b/src/lib/routes/admin-api/archive.ts @@ -7,13 +7,13 @@ import Controller from '../controller'; import { extractUsername } from '../../util/extract-user'; import { DELETE_FEATURE, UPDATE_FEATURE } from '../../types/permissions'; -import FeatureToggleServiceV2 from '../../services/feature-toggle-service'; +import FeatureToggleService from '../../services/feature-toggle-service'; import { IAuthRequest } from '../unleash-types'; export default class ArchiveController extends Controller { private readonly logger: Logger; - private featureService: FeatureToggleServiceV2; + private featureService: FeatureToggleService; constructor( config: IUnleashConfig, diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 247ad788d5..c03a219cf1 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -1,11 +1,10 @@ +import { Request, Response } from 'express'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types/services'; import EventService from '../../services/event-service'; import { ADMIN } from '../../types/permissions'; - -const Controller = require('../controller'); - -const eventDiffer = require('../../event-differ'); +import { IEvent } from '../../types/events'; +import Controller from '../controller'; const version = 1; @@ -22,32 +21,31 @@ export default class EventController extends Controller { this.get('/:name', this.getEventsForToggle); } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async getEvents(req, res): Promise { - let events; - if (req.query?.project) { - events = await this.eventService.getEventsForProject( - req.query.project, - ); + async getEvents( + req: Request, + res: Response, + ): Promise { + const { project } = req.query; + let events: IEvent[]; + if (project) { + events = await this.eventService.getEventsForProject(project); } else { events = await this.eventService.getEvents(); } - eventDiffer.addDiffs(events); res.json({ version, events }); } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async getEventsForToggle(req, res): Promise { + async getEventsForToggle( + req: Request<{ name: string }>, + res: Response, + ): Promise { const toggleName = req.params.name; const events = await this.eventService.getEventsForToggle(toggleName); if (events) { - eventDiffer.addDiffs(events); res.json({ toggleName, events }); } else { res.status(404).json({ error: 'Could not find events' }); } } } - -module.exports = EventController; diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index a91d767966..ac9bf9894c 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -11,7 +11,7 @@ import { } from '../../types/permissions'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types/services'; -import FeatureToggleServiceV2 from '../../services/feature-toggle-service'; +import FeatureToggleService from '../../services/feature-toggle-service'; import { featureSchema, querySchema } from '../../schema/feature-schema'; import { IFeatureToggleQuery } from '../../types/model'; import FeatureTagService from '../../services/feature-tag-service'; @@ -23,7 +23,7 @@ const version = 1; class FeatureController extends Controller { private tagService: FeatureTagService; - private service: FeatureToggleServiceV2; + private service: FeatureToggleService; constructor( config: IUnleashConfig, diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index ce24f4f3de..bf8c4c8a9e 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -3,7 +3,7 @@ import { applyPatch, Operation } from 'fast-json-patch'; import Controller from '../../controller'; import { IUnleashConfig } from '../../../types/option'; import { IUnleashServices } from '../../../types/services'; -import FeatureToggleServiceV2 from '../../../services/feature-toggle-service'; +import FeatureToggleService from '../../../services/feature-toggle-service'; import { Logger } from '../../../logger'; import { CREATE_FEATURE, UPDATE_FEATURE } from '../../../types/permissions'; import { @@ -52,7 +52,7 @@ type ProjectFeaturesServices = Pick< >; export default class ProjectFeaturesController extends Controller { - private featureService: FeatureToggleServiceV2; + private featureService: FeatureToggleService; private readonly logger: Logger; diff --git a/src/lib/routes/admin-api/state.ts b/src/lib/routes/admin-api/state.ts index a57f19e739..a36148b139 100644 --- a/src/lib/routes/admin-api/state.ts +++ b/src/lib/routes/admin-api/state.ts @@ -103,4 +103,3 @@ class StateController extends Controller { } } export default StateController; -module.exports = StateController; diff --git a/src/lib/routes/client-api/feature.ts b/src/lib/routes/client-api/feature.ts index 91ddc1066a..664eed7dc9 100644 --- a/src/lib/routes/client-api/feature.ts +++ b/src/lib/routes/client-api/feature.ts @@ -3,7 +3,7 @@ import { Response } from 'express'; import Controller from '../controller'; import { IUnleashServices } from '../../types/services'; import { IUnleashConfig } from '../../types/option'; -import FeatureToggleServiceV2 from '../../services/feature-toggle-service'; +import FeatureToggleService from '../../services/feature-toggle-service'; import { Logger } from '../../logger'; import { querySchema } from '../../schema/feature-schema'; import { IFeatureToggleQuery } from '../../types/model'; @@ -22,7 +22,7 @@ interface QueryOverride { export default class FeatureController extends Controller { private readonly logger: Logger; - private featureToggleServiceV2: FeatureToggleServiceV2; + private featureToggleServiceV2: FeatureToggleService; private readonly cache: boolean; diff --git a/src/lib/services/addon-service-test-simple-addon.ts b/src/lib/services/addon-service-test-simple-addon.ts index 634564b963..873df6b46d 100644 --- a/src/lib/services/addon-service-test-simple-addon.ts +++ b/src/lib/services/addon-service-test-simple-addon.ts @@ -1,11 +1,12 @@ import Addon from '../addons/addon'; import getLogger from '../../test/fixtures/no-logger'; -import { IAddonDefinition, IEvent } from '../types/model'; +import { IAddonDefinition } from '../types/model'; import { FEATURE_ARCHIVED, FEATURE_CREATED, FEATURE_REVIVED, FEATURE_UPDATED, + IEvent, } from '../types/events'; const definition: IAddonDefinition = { diff --git a/src/lib/services/client-metrics/index.ts b/src/lib/services/client-metrics/index.ts index 1b7fd49498..8e3aba8207 100644 --- a/src/lib/services/client-metrics/index.ts +++ b/src/lib/services/client-metrics/index.ts @@ -1,7 +1,7 @@ import { applicationSchema } from './metrics-schema'; import { Projection } from './projection'; import { clientMetricsSchema } from './client-metrics-schema'; -import { APPLICATION_CREATED } from '../../types/events'; +import { APPLICATION_CREATED, IBaseEvent } from '../../types/events'; import { IApplication, IYesNoCount } from './models'; import { IUnleashStores } from '../../types/stores'; import { IUnleashConfig } from '../../types/option'; @@ -15,12 +15,7 @@ import { IStrategyStore } from '../../types/stores/strategy-store'; import { IClientMetricsStore } from '../../types/stores/client-metrics-store'; import { IClientInstanceStore } from '../../types/stores/client-instance-store'; import { IApplicationQuery } from '../../types/query'; -import { - IClientApp, - ICreateEvent, - IMetricCounts, - IMetricsBucket, -} from '../../types/model'; +import { IClientApp, IMetricCounts, IMetricsBucket } from '../../types/model'; import { clientRegisterSchema } from './register-schema'; import { @@ -207,7 +202,7 @@ export default class ClientMetricsService { } } - appToEvent(app: IClientApp): ICreateEvent { + appToEvent(app: IClientApp): IBaseEvent { return { type: APPLICATION_CREATED, createdBy: app.clientIp, diff --git a/src/lib/services/event-service.ts b/src/lib/services/event-service.ts index 16ffb7197e..e31456f420 100644 --- a/src/lib/services/event-service.ts +++ b/src/lib/services/event-service.ts @@ -2,7 +2,7 @@ import { IUnleashConfig } from '../types/option'; import { IUnleashStores } from '../types/stores'; import { Logger } from '../logger'; import { IEventStore } from '../types/stores/event-store'; -import { IEvent } from '../types/model'; +import { IEvent } from '../types/events'; export default class EventService { private logger: Logger; @@ -22,7 +22,7 @@ export default class EventService { } async getEventsForToggle(name: string): Promise { - return this.eventStore.getEventsFilterByType(name); + return this.eventStore.getEventsForFeature(name); } async getEventsForProject(project: string): Promise { diff --git a/src/lib/services/feature-tag-service.ts b/src/lib/services/feature-tag-service.ts index 5d29c8c376..caf95328b1 100644 --- a/src/lib/services/feature-tag-service.ts +++ b/src/lib/services/feature-tag-service.ts @@ -37,6 +37,7 @@ class FeatureTagService { return this.featureTagStore.getAllTagsForFeature(featureName); } + // TODO: add project Id async addTag( featureName: string, tag: ITag, @@ -50,10 +51,8 @@ class FeatureTagService { await this.eventStore.store({ type: FEATURE_TAGGED, createdBy: userName, - data: { - featureName, - tag: validatedTag, - }, + featureName, + data: validatedTag, }); return validatedTag; } @@ -67,14 +66,13 @@ class FeatureTagService { await this.eventStore.store({ type: TAG_CREATED, createdBy: userName, - data: { - tag, - }, + data: tag, }); } } } + // TODO: add project Id async removeTag( featureName: string, tag: ITag, @@ -84,10 +82,8 @@ class FeatureTagService { await this.eventStore.store({ type: FEATURE_UNTAGGED, createdBy: userName, - data: { - featureName, - tag, - }, + featureName, + data: tag, }); } } diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index fe647070c4..5aae84b36d 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -7,19 +7,17 @@ import InvalidOperationError from '../error/invalid-operation-error'; import { FOREIGN_KEY_VIOLATION } from '../error/db-error'; import { featureMetadataSchema, nameSchema } from '../schema/feature-schema'; import { - FEATURE_ARCHIVED, - FEATURE_CREATED, - FEATURE_DELETED, - FEATURE_ENVIRONMENT_DISABLED, - FEATURE_ENVIRONMENT_ENABLED, - FEATURE_METADATA_UPDATED, - FEATURE_PROJECT_CHANGE, - FEATURE_REVIVED, - FEATURE_STALE_OFF, - FEATURE_STALE_ON, - FEATURE_STRATEGY_ADD, - FEATURE_STRATEGY_REMOVE, - FEATURE_STRATEGY_UPDATE, + FeatureArchivedEvent, + FeatureChangeProjectEvent, + FeatureCreatedEvent, + FeatureDeletedEvent, + FeatureEnvironmentEvent, + FeatureMetadataUpdateEvent, + FeatureRevivedEvent, + FeatureStaleEvent, + FeatureStrategyAddEvent, + FeatureStrategyRemoveEvent, + FeatureStrategyUpdateEvent, FEATURE_UPDATED, } from '../types/events'; import NotFoundError from '../error/notfound-error'; @@ -57,7 +55,7 @@ interface IFeatureStrategyContext extends IFeatureContext { environment: string; } -class FeatureToggleServiceV2 { +class FeatureToggleService { private logger: Logger; private featureStrategiesStore: IFeatureStrategiesStore; @@ -66,7 +64,7 @@ class FeatureToggleServiceV2 { private featureToggleClientStore: IFeatureToggleClientStore; - private featureTagStore: IFeatureTagStore; + private tagStore: IFeatureTagStore; private featureEnvironmentStore: IFeatureEnvironmentStore; @@ -99,7 +97,7 @@ class FeatureToggleServiceV2 { this.featureStrategiesStore = featureStrategiesStore; this.featureToggleStore = featureToggleStore; this.featureToggleClientStore = featureToggleClientStore; - this.featureTagStore = featureTagStore; + this.tagStore = featureTagStore; this.projectStore = projectStore; this.eventStore = eventStore; this.featureEnvironmentStore = featureEnvironmentStore; @@ -135,9 +133,9 @@ class FeatureToggleServiceV2 { } async patchFeature( - projectId: string, + project: string, featureName: string, - userName: string, + createdBy: string, operations: Operation[], ): Promise { const featureToggle = await this.getFeatureMetadata(featureName); @@ -146,26 +144,45 @@ class FeatureToggleServiceV2 { deepClone(featureToggle), operations, ); + const updated = await this.updateFeatureToggle( - projectId, + project, newDocument, - userName, + createdBy, ); + if (featureToggle.stale !== newDocument.stale) { - await this.eventStore.store({ - type: newDocument.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF, - data: updated, - project: projectId, - createdBy: userName, - }); + const tags = await this.tagStore.getAllTagsForFeature(featureName); + + await this.eventStore.store( + new FeatureStaleEvent({ + stale: newDocument.stale, + project, + featureName, + createdBy, + tags, + }), + ); } + return updated; } + featureStrategyToPublic( + featureStrategy: IFeatureStrategy, + ): IStrategyConfig { + return { + id: featureStrategy.id, + name: featureStrategy.strategyName, + constraints: featureStrategy.constraints || [], + parameters: featureStrategy.parameters, + }; + } + async createStrategy( strategyConfig: Omit, context: IFeatureStrategyContext, - userName: string, + createdBy: string, ): Promise { const { featureName, projectId, environment } = context; await this.validateFeatureContext(context); @@ -180,24 +197,20 @@ class FeatureToggleServiceV2 { featureName, environment, }); - const data = { - id: newFeatureStrategy.id, - name: newFeatureStrategy.strategyName, - constraints: newFeatureStrategy.constraints, - parameters: newFeatureStrategy.parameters, - }; - await this.eventStore.store({ - type: FEATURE_STRATEGY_ADD, - project: projectId, - createdBy: userName, - environment, - data: { - ...data, - name: featureName, // Done like this since we use data as our return object. - strategyName: newFeatureStrategy.strategyName, - }, - }); - return data; + + const tags = await this.tagStore.getAllTagsForFeature(featureName); + const strategy = this.featureStrategyToPublic(newFeatureStrategy); + await this.eventStore.store( + new FeatureStrategyAddEvent({ + project: projectId, + featureName, + createdBy, + environment, + data: strategy, + tags, + }), + ); + return strategy; } catch (e) { if (e.code === FOREIGN_KEY_VIOLATION) { throw new BadDataError( @@ -224,7 +237,7 @@ class FeatureToggleServiceV2 { context: IFeatureStrategyContext, userName: string, ): Promise { - const { projectId, environment } = context; + const { projectId, environment, featureName } = context; const existingStrategy = await this.featureStrategiesStore.get(id); this.validateFeatureStrategyContext(existingStrategy, context); @@ -233,20 +246,22 @@ class FeatureToggleServiceV2 { id, updates, ); - const data = { - id: strategy.id, - name: strategy.strategyName, - featureName: strategy.featureName, - constraints: strategy.constraints || [], - parameters: strategy.parameters, - }; - await this.eventStore.store({ - type: FEATURE_STRATEGY_UPDATE, - project: projectId, - environment, - createdBy: userName, - data, - }); + + // Store event! + const tags = await this.tagStore.getAllTagsForFeature(featureName); + const data = this.featureStrategyToPublic(strategy); + const preData = this.featureStrategyToPublic(existingStrategy); + await this.eventStore.store( + new FeatureStrategyUpdateEvent({ + project: projectId, + featureName, + environment, + createdBy: userName, + data, + preData, + tags, + }), + ); return data; } throw new NotFoundError(`Could not find strategy with id ${id}`); @@ -259,7 +274,7 @@ class FeatureToggleServiceV2 { context: IFeatureStrategyContext, userName: string, ): Promise { - const { projectId, environment } = context; + const { projectId, environment, featureName } = context; const existingStrategy = await this.featureStrategiesStore.get(id); this.validateFeatureStrategyContext(existingStrategy, context); @@ -270,19 +285,20 @@ class FeatureToggleServiceV2 { id, existingStrategy, ); - const data = { - id: strategy.id, - name: strategy.strategyName, - constraints: strategy.constraints || [], - parameters: strategy.parameters, - }; - await this.eventStore.store({ - type: FEATURE_STRATEGY_UPDATE, - project: projectId, - environment, - createdBy: userName, - data, - }); + const tags = await this.tagStore.getAllTagsForFeature(featureName); + const data = this.featureStrategyToPublic(strategy); + const preData = this.featureStrategyToPublic(existingStrategy); + await this.eventStore.store( + new FeatureStrategyUpdateEvent({ + featureName, + project: projectId, + environment, + createdBy: userName, + data, + preData, + tags, + }), + ); return data; } throw new NotFoundError(`Could not find strategy with id ${id}`); @@ -300,23 +316,27 @@ class FeatureToggleServiceV2 { async deleteStrategy( id: string, context: IFeatureStrategyContext, - userName: string, + createdBy: string, ): Promise { const existingStrategy = await this.featureStrategiesStore.get(id); const { featureName, projectId, environment } = context; this.validateFeatureStrategyContext(existingStrategy, context); await this.featureStrategiesStore.delete(id); - await this.eventStore.store({ - type: FEATURE_STRATEGY_REMOVE, - project: projectId, - environment, - createdBy: userName, - data: { - id, - name: featureName, - }, - }); + + const tags = await this.tagStore.getAllTagsForFeature(featureName); + const preData = this.featureStrategyToPublic(existingStrategy); + + await this.eventStore.store( + new FeatureStrategyRemoveEvent({ + featureName, + project: projectId, + environment, + createdBy, + preData, + tags, + }), + ); // If there are no strategies left for environment disable it await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies( @@ -419,32 +439,36 @@ class FeatureToggleServiceV2 { async createFeatureToggle( projectId: string, value: FeatureToggleDTO, - userName: string, + createdBy: string, ): Promise { - this.logger.info(`${userName} creates feature toggle ${value.name}`); + this.logger.info(`${createdBy} creates feature toggle ${value.name}`); await this.validateName(value.name); const exists = await this.projectStore.hasProject(projectId); if (exists) { const featureData = await featureMetadataSchema.validateAsync( value, ); + const featureName = featureData.name; const createdToggle = await this.featureToggleStore.create( projectId, featureData, ); await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject( - featureData.name, + featureName, projectId, ); - const data = { ...featureData, project: projectId }; + const tags = await this.tagStore.getAllTagsForFeature(featureName); - await this.eventStore.store({ - type: FEATURE_CREATED, - createdBy: userName, - project: projectId, - data, - }); + await this.eventStore.store( + new FeatureCreatedEvent({ + featureName, + createdBy, + project: projectId, + data: createdToggle, + tags, + }), + ); return createdToggle; } @@ -516,21 +540,24 @@ class FeatureToggleServiceV2 { updatedFeature, ); + const preData = await this.featureToggleStore.get(featureName); + const featureToggle = await this.featureToggleStore.update( projectId, featureData, ); - const tags = await this.featureTagStore.getAllTagsForFeature( - featureName, - ); + const tags = await this.tagStore.getAllTagsForFeature(featureName); - await this.eventStore.store({ - type: FEATURE_METADATA_UPDATED, - createdBy: userName, - data: featureToggle, - project: projectId, - tags, - }); + await this.eventStore.store( + new FeatureMetadataUpdateEvent({ + createdBy: userName, + data: featureToggle, + preData, + featureName, + project: projectId, + tags, + }), + ); return featureToggle; } @@ -587,6 +614,7 @@ class FeatureToggleServiceV2 { }; } + // todo: store events for this change. async deleteEnvironment( projectId: string, environment: string, @@ -609,7 +637,7 @@ class FeatureToggleServiceV2 { } async validateUniqueFeatureName(name: string): Promise { - let msg; + let msg: string; try { const feature = await this.featureToggleStore.get(name); msg = feature.archived @@ -628,46 +656,48 @@ class FeatureToggleServiceV2 { async updateStale( featureName: string, isStale: boolean, - userName: string, + createdBy: string, ): Promise { const feature = await this.featureToggleStore.get(featureName); + const { project } = feature; feature.stale = isStale; - await this.featureToggleStore.update(feature.project, feature); - const tags = await this.featureTagStore.getAllTagsForFeature( - featureName, - ); - const data = await this.getFeatureToggleLegacy(featureName); + await this.featureToggleStore.update(project, feature); + const tags = await this.tagStore.getAllTagsForFeature(featureName); + + await this.eventStore.store( + new FeatureStaleEvent({ + stale: isStale, + project, + featureName, + createdBy, + tags, + }), + ); - await this.eventStore.store({ - type: isStale ? FEATURE_STALE_ON : FEATURE_STALE_OFF, - createdBy: userName, - data, - tags, - project: feature.project, - }); return feature; } - async archiveToggle(name: string, userName: string): Promise { - const feature = await this.featureToggleStore.get(name); - await this.featureToggleStore.archive(name); - const tags = - (await this.featureTagStore.getAllTagsForFeature(name)) || []; - await this.eventStore.store({ - type: FEATURE_ARCHIVED, - createdBy: userName, - data: { name }, - project: feature.project, - tags, - }); + // todo: add projectId + async archiveToggle(featureName: string, createdBy: string): Promise { + const feature = await this.featureToggleStore.get(featureName); + await this.featureToggleStore.archive(featureName); + const tags = await this.tagStore.getAllTagsForFeature(featureName); + await this.eventStore.store( + new FeatureArchivedEvent({ + featureName, + createdBy, + project: feature.project, + tags, + }), + ); } async updateEnabled( - projectId: string, + project: string, featureName: string, environment: string, enabled: boolean, - userName: string, + createdBy: string, ): Promise { const hasEnvironment = await this.featureEnvironmentStore.featureHasEnvironment( @@ -678,7 +708,7 @@ class FeatureToggleServiceV2 { if (hasEnvironment) { if (enabled) { const strategies = await this.getStrategiesForEnvironment( - projectId, + project, featureName, environment, ); @@ -697,19 +727,19 @@ class FeatureToggleServiceV2 { const feature = await this.featureToggleStore.get(featureName); if (updatedEnvironmentStatus > 0) { - const tags = await this.featureTagStore.getAllTagsForFeature( + const tags = await this.tagStore.getAllTagsForFeature( featureName, ); - await this.eventStore.store({ - type: enabled - ? FEATURE_ENVIRONMENT_ENABLED - : FEATURE_ENVIRONMENT_DISABLED, - createdBy: userName, - data: { name: featureName }, - tags, - project: projectId, - environment, - }); + await this.eventStore.store( + new FeatureEnvironmentEvent({ + enabled, + project, + featureName, + environment, + createdBy, + tags, + }), + ); } return feature; } @@ -718,18 +748,20 @@ class FeatureToggleServiceV2 { ); } + // @deprecated async storeFeatureUpdatedEventLegacy( featureName: string, - userName: string, + createdBy: string, ): Promise { - const tags = await this.featureTagStore.getAllTagsForFeature( - featureName, - ); + const tags = await this.tagStore.getAllTagsForFeature(featureName); const feature = await this.getFeatureToggleLegacy(featureName); + // Legacy event. Will not be used from v4.3. + // We do not include 'preData' on purpose. await this.eventStore.store({ type: FEATURE_UPDATED, - createdBy: userName, + createdBy, + featureName, data: feature, tags, project: feature.project, @@ -759,6 +791,7 @@ class FeatureToggleServiceV2 { ); } + // @deprecated async getFeatureToggleLegacy( featureName: string, ): Promise { @@ -777,56 +810,57 @@ class FeatureToggleServiceV2 { async changeProject( featureName: string, newProject: string, - userName: string, + createdBy: string, ): Promise { const feature = await this.featureToggleStore.get(featureName); const oldProject = feature.project; feature.project = newProject; await this.featureToggleStore.update(newProject, feature); - const tags = await this.featureTagStore.getAllTagsForFeature( - featureName, - ); - await this.eventStore.store({ - type: FEATURE_PROJECT_CHANGE, - createdBy: userName, - data: { - name: feature.name, + const tags = await this.tagStore.getAllTagsForFeature(featureName); + await this.eventStore.store( + new FeatureChangeProjectEvent({ + createdBy, oldProject, newProject, - }, - project: newProject, - tags, - }); + featureName, + tags, + }), + ); } async getArchivedFeatures(): Promise { return this.getFeatureToggles({}, true); } - async deleteFeature(featureName: string, userName: string): Promise { + // TODO: add project id. + async deleteFeature(featureName: string, createdBy: string): Promise { + const toggle = await this.featureToggleStore.get(featureName); + const tags = await this.tagStore.getAllTagsForFeature(featureName); await this.featureToggleStore.delete(featureName); - await this.eventStore.store({ - type: FEATURE_DELETED, - createdBy: userName, - data: { + await this.eventStore.store( + new FeatureDeletedEvent({ featureName, - }, - }); + project: toggle.project, + createdBy, + preData: toggle, + tags, + }), + ); } - async reviveToggle(featureName: string, userName: string): Promise { - const data = await this.featureToggleStore.revive(featureName); - const tags = await this.featureTagStore.getAllTagsForFeature( - featureName, + // TODO: add project id. + async reviveToggle(featureName: string, createdBy: string): Promise { + const toggle = await this.featureToggleStore.revive(featureName); + const tags = await this.tagStore.getAllTagsForFeature(featureName); + await this.eventStore.store( + new FeatureRevivedEvent({ + createdBy, + featureName, + project: toggle.project, + tags, + }), ); - await this.eventStore.store({ - type: FEATURE_REVIVED, - createdBy: userName, - data, - project: data.project, - tags, - }); } async getMetadataForAllFeatures( @@ -850,5 +884,4 @@ class FeatureToggleServiceV2 { } } -module.exports = FeatureToggleServiceV2; -export default FeatureToggleServiceV2; +export default FeatureToggleService; diff --git a/src/lib/services/project-health-service.ts b/src/lib/services/project-health-service.ts index eddacba478..0dee3b60f0 100644 --- a/src/lib/services/project-health-service.ts +++ b/src/lib/services/project-health-service.ts @@ -11,7 +11,7 @@ import { import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; import { IFeatureTypeStore } from '../types/stores/feature-type-store'; import { IProjectStore } from '../types/stores/project-store'; -import FeatureToggleServiceV2 from './feature-toggle-service'; +import FeatureToggleService from './feature-toggle-service'; import { hoursToMilliseconds } from 'date-fns'; import Timer = NodeJS.Timer; @@ -28,7 +28,7 @@ export default class ProjectHealthService { private healthRatingTimer: Timer; - private featureToggleService: FeatureToggleServiceV2; + private featureToggleService: FeatureToggleService; constructor( { @@ -40,7 +40,7 @@ export default class ProjectHealthService { 'projectStore' | 'featureTypeStore' | 'featureToggleStore' >, { getLogger }: Pick, - featureToggleService: FeatureToggleServiceV2, + featureToggleService: FeatureToggleService, ) { this.logger = getLogger('services/project-health-service.ts'); this.projectStore = projectStore; diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index ba5b29c079..ac4d2c9fe4 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -27,7 +27,7 @@ import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-st import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import { IRole } from '../types/stores/access-store'; import { IEventStore } from '../types/stores/event-store'; -import FeatureToggleServiceV2 from './feature-toggle-service'; +import FeatureToggleService from './feature-toggle-service'; import { CREATE_FEATURE, UPDATE_FEATURE } from '../types/permissions'; import NoAccessError from '../error/no-access-error'; import IncompatibleProjectError from '../error/incompatible-project-error'; @@ -58,7 +58,7 @@ export default class ProjectService { private logger: any; - private featureToggleService: FeatureToggleServiceV2; + private featureToggleService: FeatureToggleService; private environmentsEnabled: boolean = false; @@ -81,7 +81,7 @@ export default class ProjectService { >, config: IUnleashConfig, accessService: AccessService, - featureToggleService: FeatureToggleServiceV2, + featureToggleService: FeatureToggleService, ) { this.store = projectStore; this.environmentStore = environmentStore; @@ -161,16 +161,17 @@ export default class ProjectService { } async updateProject(updatedProject: IProject, user: User): Promise { - await this.store.get(updatedProject.id); + const preData = await this.store.get(updatedProject.id); const project = await projectSchema.validateAsync(updatedProject); await this.store.update(project); await this.eventStore.store({ type: PROJECT_UPDATED, + project: project.id, createdBy: getCreatedBy(user), data: project, - project: project.id, + preData, }); } @@ -258,7 +259,6 @@ export default class ProjectService { type: PROJECT_DELETED, createdBy: getCreatedBy(user), project: id, - data: { id }, }); await this.accessService.removeDefaultProjectRoles(user, id); @@ -289,6 +289,7 @@ export default class ProjectService { }; } + // TODO: should be an event too async addUser( projectId: string, roleId: number, @@ -313,6 +314,7 @@ export default class ProjectService { await this.accessService.addUserToRole(userId, role.id); } + // TODO: should be an event too async removeUser( projectId: string, roleId: number, diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 2bb5778641..097f5a10fe 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -206,26 +206,29 @@ class UserService { await this.store.setPasswordHash(user.id, passwordHash); } - await this.updateChangeLog(USER_CREATED, user, updatedBy); + await this.eventStore.store({ + type: USER_CREATED, + createdBy: this.getCreatedBy(updatedBy), + data: this.mapUserToData(user), + }); return user; } - private async updateChangeLog( - type: string, - user: IUser, - updatedBy: User = systemUser, - ): Promise { - await this.eventStore.store({ - type, - createdBy: updatedBy.username || updatedBy.email, - data: { - id: user.id, - name: user.name, - username: user.username, - email: user.email, - }, - }); + private getCreatedBy(updatedBy: User = systemUser) { + return updatedBy.username || updatedBy.email; + } + + private mapUserToData(user?: IUser): any { + if (!user) { + return undefined; + } + return { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + }; } async updateUser( @@ -236,17 +239,43 @@ class UserService { Joi.assert(email, Joi.string().email(), 'Email'); } + const preUser = await this.store.get(id); + if (rootRole) { await this.accessService.setUserRootRole(id, rootRole); } const user = await this.store.update(id, { name, email }); - await this.updateChangeLog(USER_UPDATED, user, updatedBy); + await this.eventStore.store({ + type: USER_UPDATED, + createdBy: this.getCreatedBy(updatedBy), + data: this.mapUserToData(user), + preData: this.mapUserToData(preUser), + }); return user; } + async deleteUser(userId: number, updatedBy?: User): Promise { + const user = await this.store.get(userId); + const roles = await this.accessService.getRolesForUser(userId); + await Promise.all( + roles.map((role) => + this.accessService.removeUserFromRole(userId, role.id), + ), + ); + await this.sessionService.deleteSessionsForUser(userId); + + await this.store.delete(userId); + + await this.eventStore.store({ + type: USER_DELETED, + createdBy: this.getCreatedBy(updatedBy), + preData: this.mapUserToData(user), + }); + } + async loginUser(usernameOrEmail: string, password: string): Promise { const settings = await this.settingService.get( simpleAuthKey, @@ -323,21 +352,6 @@ class UserService { return this.store.setPasswordHash(userId, passwordHash); } - async deleteUser(userId: number, updatedBy?: User): Promise { - const user = await this.store.get(userId); - const roles = await this.accessService.getRolesForUser(userId); - await Promise.all( - roles.map((role) => - this.accessService.removeUserFromRole(userId, role.id), - ), - ); - await this.sessionService.deleteSessionsForUser(userId); - - await this.store.delete(userId); - - await this.updateChangeLog(USER_DELETED, user, updatedBy); - } - async getUserForToken(token: string): Promise { const { createdBy, userId } = await this.resetTokenService.isValid( token, diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 570317ea14..9a3a10eb40 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -1,4 +1,8 @@ +import { FeatureToggle, IStrategyConfig, ITag } from './model'; + export const APPLICATION_CREATED = 'application-created'; + +// feature event types export const FEATURE_CREATED = 'feature-created'; export const FEATURE_DELETED = 'feature-deleted'; export const FEATURE_UPDATED = 'feature-updated'; @@ -17,6 +21,9 @@ export const FEATURE_UNTAGGED = 'feature-untagged'; export const FEATURE_STALE_ON = 'feature-stale-on'; export const FEATURE_STALE_OFF = 'feature-stale-off'; export const DROP_FEATURES = 'drop-features'; +export const FEATURE_ENVIRONMENT_ENABLED = 'feature-environment-enabled'; +export const FEATURE_ENVIRONMENT_DISABLED = 'feature-environment-disabled'; + export const STRATEGY_CREATED = 'strategy-created'; export const STRATEGY_DELETED = 'strategy-deleted'; export const STRATEGY_DEPRECATED = 'strategy-deprecated'; @@ -50,5 +57,296 @@ export const USER_UPDATED = 'user-updated'; export const USER_DELETED = 'user-deleted'; export const DROP_ENVIRONMENTS = 'drop-environments'; export const ENVIRONMENT_IMPORT = 'environment-import'; -export const FEATURE_ENVIRONMENT_ENABLED = 'feature-environment-enabled'; -export const FEATURE_ENVIRONMENT_DISABLED = 'feature-environment-disabled'; + +export interface IBaseEvent { + type: string; + createdBy: string; + project?: string; + environment?: string; + featureName?: string; + data?: any; + preData?: any; + tags?: ITag[]; +} + +export interface IEvent extends IBaseEvent { + id: number; + createdAt: Date; +} + +class BaseEvent implements IBaseEvent { + readonly type: string; + + readonly createdBy: string; + + readonly tags: ITag[]; + + constructor(type: string, createdBy: string, tags: ITag[] = []) { + this.type = type; + this.createdBy = createdBy; + this.tags = tags; + } +} + +export class FeatureStaleEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + constructor(p: { + stale: boolean; + project: string; + featureName: string; + createdBy: string; + tags: ITag[]; + }) { + super( + p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF, + p.createdBy, + p.tags, + ); + this.project = p.project; + this.featureName = p.featureName; + } +} + +export class FeatureEnvironmentEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly environment: string; + + constructor(p: { + enabled: boolean; + project: string; + featureName: string; + environment: string; + createdBy: string; + tags: ITag[]; + }) { + super( + p.enabled + ? FEATURE_ENVIRONMENT_ENABLED + : FEATURE_ENVIRONMENT_DISABLED, + p.createdBy, + p.tags, + ); + this.project = p.project; + this.featureName = p.featureName; + this.environment = p.environment; + } +} + +export class FeatureChangeProjectEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly data: { + oldProject: string; + newProject: string; + }; + + constructor(p: { + oldProject: string; + newProject: string; + featureName: string; + createdBy: string; + tags: ITag[]; + }) { + super(FEATURE_PROJECT_CHANGE, p.createdBy, p.tags); + const { newProject, oldProject, featureName } = p; + this.project = newProject; + this.featureName = featureName; + this.data = { newProject, oldProject }; + } +} + +export class FeatureCreatedEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly data: FeatureToggle; + + constructor(p: { + project: string; + featureName: string; + createdBy: string; + data: FeatureToggle; + tags: ITag[]; + }) { + super(FEATURE_CREATED, p.createdBy, p.tags); + const { project, featureName, data } = p; + this.project = project; + this.featureName = featureName; + this.data = data; + } +} + +export class FeatureArchivedEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + constructor(p: { + project: string; + featureName: string; + createdBy: string; + tags: ITag[]; + }) { + super(FEATURE_ARCHIVED, p.createdBy, p.tags); + const { project, featureName } = p; + this.project = project; + this.featureName = featureName; + } +} + +export class FeatureRevivedEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + constructor(p: { + project: string; + featureName: string; + createdBy: string; + tags: ITag[]; + }) { + super(FEATURE_REVIVED, p.createdBy, p.tags); + const { project, featureName } = p; + this.project = project; + this.featureName = featureName; + } +} + +export class FeatureDeletedEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly preData: FeatureToggle; + + constructor(p: { + project: string; + featureName: string; + preData: FeatureToggle; + createdBy: string; + tags: ITag[]; + }) { + super(FEATURE_DELETED, p.createdBy, p.tags); + const { project, featureName, preData } = p; + this.project = project; + this.featureName = featureName; + this.preData = preData; + } +} + +export class FeatureMetadataUpdateEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly data: FeatureToggle; + + readonly preData: FeatureToggle; + + constructor(p: { + featureName: string; + createdBy: string; + project: string; + data: FeatureToggle; + preData: FeatureToggle; + tags: ITag[]; + }) { + super(FEATURE_METADATA_UPDATED, p.createdBy, p.tags); + const { project, featureName, data, preData } = p; + this.project = project; + this.featureName = featureName; + this.data = data; + this.preData = preData; + } +} + +export class FeatureStrategyAddEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly environment: string; + + readonly data: IStrategyConfig; + + constructor(p: { + project: string; + featureName: string; + environment: string; + createdBy: string; + data: IStrategyConfig; + tags: ITag[]; + }) { + super(FEATURE_STRATEGY_ADD, p.createdBy, p.tags); + const { project, featureName, environment, data } = p; + this.project = project; + this.featureName = featureName; + this.environment = environment; + this.data = data; + } +} + +export class FeatureStrategyUpdateEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly environment: string; + + readonly data: IStrategyConfig; + + readonly preData: IStrategyConfig; + + constructor(p: { + project: string; + featureName: string; + environment: string; + createdBy: string; + data: IStrategyConfig; + preData: IStrategyConfig; + tags: ITag[]; + }) { + super(FEATURE_STRATEGY_UPDATE, p.createdBy, p.tags); + const { project, featureName, environment, data, preData } = p; + this.project = project; + this.featureName = featureName; + this.environment = environment; + this.data = data; + this.preData = preData; + } +} + +export class FeatureStrategyRemoveEvent extends BaseEvent { + readonly project: string; + + readonly featureName: string; + + readonly environment: string; + + readonly preData: IStrategyConfig; + + constructor(p: { + project: string; + featureName: string; + environment: string; + createdBy: string; + preData: IStrategyConfig; + tags: ITag[]; + }) { + super(FEATURE_STRATEGY_REMOVE, p.createdBy, p.tags); + const { project, featureName, environment, preData } = p; + this.project = project; + this.featureName = featureName; + this.environment = environment; + this.preData = preData; + } +} diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index b01aa19d1d..f19a8c9746 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -196,20 +196,6 @@ export interface IAddonConfig { unleashUrl: string; } -export interface ICreateEvent { - type: string; - createdBy: string; - project?: string; - environment?: string; - data?: any; - tags?: ITag[]; -} - -export interface IEvent extends ICreateEvent { - id: number; - createdAt: Date; -} - export interface IUserWithRole { id: number; roleId: number; diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index e537c59bc1..365cf89ae8 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -1,11 +1,12 @@ import EventEmitter from 'events'; -import { ICreateEvent, IEvent } from '../model'; +import { IBaseEvent, IEvent } from '../events'; import { Store } from './store'; export interface IEventStore extends Store, EventEmitter { - store(event: ICreateEvent): Promise; - batchStore(events: ICreateEvent[]): Promise; + store(event: IBaseEvent): Promise; + batchStore(events: IBaseEvent[]): Promise; getEvents(): Promise; getEventsFilterByType(name: string): Promise; + getEventsForFeature(featureName: string): Promise; getEventsFilterByProject(project: string): Promise; } diff --git a/src/migrations/20211105104316-add-feature-name-column-to-events.js b/src/migrations/20211105104316-add-feature-name-column-to-events.js new file mode 100644 index 0000000000..a1f8e9bb59 --- /dev/null +++ b/src/migrations/20211105104316-add-feature-name-column-to-events.js @@ -0,0 +1,23 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE events + ADD COLUMN feature_name TEXT; + CREATE INDEX feature_name_idx ON events(feature_name); + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + DROP INDEX feature_name_idx; + ALTER TABLE events + DROP COLUMN feature_name; + `, + cb, + ); +}; diff --git a/src/migrations/20211105105509-add-predata-column-to-events.js b/src/migrations/20211105105509-add-predata-column-to-events.js new file mode 100644 index 0000000000..70e20d808b --- /dev/null +++ b/src/migrations/20211105105509-add-predata-column-to-events.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql(`ALTER TABLE events ADD COLUMN pre_data jsonb;`, cb); +}; + +exports.down = function (db, cb) { + db.runSql(`ALTER TABLE events DROP COLUMN pre_data;`, cb); +}; diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index 7ddc479d8f..261f1e3cc4 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -556,9 +556,8 @@ test('Patching feature toggles to stale should trigger FEATURE_STALE_ON event', const events = await db.stores.eventStore.getAll({ type: FEATURE_STALE_ON, }); - const updateForOurToggle = events.find((e) => e.data.name === name); + const updateForOurToggle = events.find((e) => e.featureName === name); expect(updateForOurToggle).toBeTruthy(); - expect(updateForOurToggle.data.stale).toBe(true); }); test('Patching feature toggles to active (turning stale to false) should trigger FEATURE_STALE_OFF event', async () => { @@ -581,9 +580,8 @@ test('Patching feature toggles to active (turning stale to false) should trigger const events = await db.stores.eventStore.getAll({ type: FEATURE_STALE_OFF, }); - const updateForOurToggle = events.find((e) => e.data.name === name); + const updateForOurToggle = events.find((e) => e.featureName === name); expect(updateForOurToggle).toBeTruthy(); - expect(updateForOurToggle.data.stale).toBe(false); }); test('Should archive feature toggle', async () => { @@ -1149,9 +1147,9 @@ test('Deleting a strategy should include name of feature strategy was deleted fr type: FEATURE_STRATEGY_REMOVE, }); expect(events).toHaveLength(1); - expect(events[0].data.name).toBe(featureName); + expect(events[0].featureName).toBe(featureName); expect(events[0].environment).toBe(environment); - expect(events[0].data.id).toBe(strategyId); + expect(events[0].preData.id).toBe(strategyId); }); test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async () => { @@ -1193,7 +1191,7 @@ test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async ( const events = await db.stores.eventStore.getAll({ type: FEATURE_ENVIRONMENT_ENABLED, }); - const enabledEvents = events.filter((e) => e.data.name === featureName); + const enabledEvents = events.filter((e) => e.featureName === featureName); expect(enabledEvents).toHaveLength(1); }); test('Disabling environment creates a FEATURE_ENVIRONMENT_DISABLED event', async () => { @@ -1243,7 +1241,7 @@ test('Disabling environment creates a FEATURE_ENVIRONMENT_DISABLED event', async const events = await db.stores.eventStore.getAll({ type: FEATURE_ENVIRONMENT_DISABLED, }); - const ourFeatureEvent = events.find((e) => e.data.name === featureName); + const ourFeatureEvent = events.find((e) => e.featureName === featureName); expect(ourFeatureEvent).toBeTruthy(); }); diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts index e57b1a254c..7c77b96edc 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -44,8 +44,6 @@ afterEach(async () => { }); test('returns empty list of users', async () => { - expect.assertions(1); - return app.request .get('/api/admin/user-admin') .expect('Content-Type', /json/) @@ -56,8 +54,6 @@ test('returns empty list of users', async () => { }); test('creates and returns all users', async () => { - expect.assertions(2); - const createUserRequests = [...Array(20).keys()].map((i) => app.request .post('/api/admin/user-admin') @@ -82,8 +78,6 @@ test('creates and returns all users', async () => { }); test('creates editor-user without password', async () => { - expect.assertions(3); - return app.request .post('/api/admin/user-admin') .send({ @@ -101,8 +95,6 @@ test('creates editor-user without password', async () => { }); test('creates admin-user with password', async () => { - expect.assertions(6); - const { body } = await app.request .post('/api/admin/user-admin') .send({ @@ -129,8 +121,6 @@ test('creates admin-user with password', async () => { }); test('requires known root role', async () => { - expect.assertions(0); - return app.request .post('/api/admin/user-admin') .send({ @@ -186,16 +176,12 @@ test('get a single user', async () => { }); test('should delete user', async () => { - expect.assertions(0); - const user = await userStore.insert({ email: 'some@mail.com' }); return app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200); }); test('validator should require strong password', async () => { - expect.assertions(0); - return app.request .post('/api/admin/user-admin/validate-password') .send({ password: 'simple' }) @@ -203,8 +189,6 @@ test('validator should require strong password', async () => { }); test('validator should accept strong password', async () => { - expect.assertions(0); - return app.request .post('/api/admin/user-admin/validate-password') .send({ password: 'simple123-_ASsad' }) @@ -212,8 +196,6 @@ test('validator should accept strong password', async () => { }); test('should change password', async () => { - expect.assertions(0); - const user = await userStore.insert({ email: 'some@mail.com' }); return app.request @@ -223,8 +205,6 @@ test('should change password', async () => { }); test('should search for users', async () => { - expect.assertions(2); - await userStore.insert({ email: 'some@mail.com' }); await userStore.insert({ email: 'another@mail.com' }); await userStore.insert({ email: 'another2@mail.com' }); @@ -241,8 +221,6 @@ test('should search for users', async () => { }); test('Creates a user and includes inviteLink and emailConfigured', async () => { - expect.assertions(5); - return app.request .post('/api/admin/user-admin') .send({ @@ -300,7 +278,6 @@ test('Creates a user but does not send email if sendEmail is set to false', asyn }); test('generates USER_CREATED event', async () => { - expect.assertions(5); const email = 'some@getunelash.ai'; const name = 'Some Name'; @@ -325,20 +302,16 @@ test('generates USER_CREATED event', async () => { }); test('generates USER_DELETED event', async () => { - expect.assertions(3); - const user = await userStore.insert({ email: 'some@mail.com' }); await app.request.delete(`/api/admin/user-admin/${user.id}`); const events = await eventStore.getEvents(); expect(events[0].type).toBe(USER_DELETED); - expect(events[0].data.id).toBe(user.id); - expect(events[0].data.email).toBe(user.email); + expect(events[0].preData.id).toBe(user.id); + expect(events[0].preData.email).toBe(user.email); }); test('generates USER_UPDATED event', async () => { - expect.assertions(3); - const { body } = await app.request .post('/api/admin/user-admin') .send({ diff --git a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts index 121a4936ba..706d89f99f 100644 --- a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts +++ b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts @@ -1,4 +1,4 @@ -import FeatureToggleServiceV2 from '../../../lib/services/feature-toggle-service'; +import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import { IStrategyConfig } from '../../../lib/types/model'; import { createTestConfig } from '../../config/test-config'; import dbInit from '../helpers/database-init'; @@ -6,7 +6,7 @@ import { DEFAULT_ENV } from '../../../lib/util/constants'; let stores; let db; -let service: FeatureToggleServiceV2; +let service: FeatureToggleService; beforeAll(async () => { const config = createTestConfig(); @@ -15,7 +15,7 @@ beforeAll(async () => { config.getLogger, ); stores = db.stores; - service = new FeatureToggleServiceV2(stores, config); + service = new FeatureToggleService(stores, config); }); afterAll(async () => { diff --git a/src/test/e2e/services/project-health-service.e2e.test.ts b/src/test/e2e/services/project-health-service.e2e.test.ts index 81ba8ca8ac..19de4de859 100644 --- a/src/test/e2e/services/project-health-service.e2e.test.ts +++ b/src/test/e2e/services/project-health-service.e2e.test.ts @@ -1,6 +1,6 @@ import dbInit, { ITestDb } from '../helpers/database-init'; import getLogger from '../../fixtures/no-logger'; -import FeatureToggleServiceV2 from '../../../lib/services/feature-toggle-service'; +import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import { AccessService } from '../../../lib/services/access-service'; import ProjectService from '../../../lib/services/project-service'; import ProjectHealthService from '../../../lib/services/project-health-service'; @@ -25,7 +25,7 @@ beforeAll(async () => { email: 'test@getunleash.io', }); accessService = new AccessService(stores, config); - featureToggleService = new FeatureToggleServiceV2(stores, config); + featureToggleService = new FeatureToggleService(stores, config); projectService = new ProjectService( stores, config, diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 2c9fdcc3a1..951b459cf7 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -1,6 +1,6 @@ import dbInit, { ITestDb } from '../helpers/database-init'; import getLogger from '../../fixtures/no-logger'; -import FeatureToggleServiceV2 from '../../../lib/services/feature-toggle-service'; +import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import ProjectService from '../../../lib/services/project-service'; import { AccessService } from '../../../lib/services/access-service'; import { @@ -17,7 +17,7 @@ let db: ITestDb; let projectService; let accessService; -let featureToggleService: FeatureToggleServiceV2; +let featureToggleService: FeatureToggleService; let user; beforeAll(async () => { @@ -33,7 +33,7 @@ beforeAll(async () => { experimental: { environments: { enabled: true } }, }); accessService = new AccessService(stores, config); - featureToggleService = new FeatureToggleServiceV2(stores, config); + featureToggleService = new FeatureToggleService(stores, config); projectService = new ProjectService( stores, config, diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 186e6a1af7..8cfb4cd859 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -1,11 +1,11 @@ import { APPLICATION_CREATED, FEATURE_CREATED, + IEvent, } from '../../../lib/types/events'; import dbInit from '../helpers/database-init'; import getLogger from '../../fixtures/no-logger'; -import { IEvent } from '../../../lib/types/model'; import { IEventStore } from '../../../lib/types/stores/event-store'; import { IUnleashStores } from '../../../lib/types'; diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 050233cb2e..8fb83b1243 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -1,6 +1,6 @@ import EventEmitter from 'events'; import { IEventStore } from '../../lib/types/stores/event-store'; -import { IEvent } from '../../lib/types/model'; +import { IEvent } from '../../lib/types/events'; class FakeEventStore extends EventEmitter implements IEventStore { events: IEvent[]; @@ -11,6 +11,10 @@ class FakeEventStore extends EventEmitter implements IEventStore { this.events = []; } + async getEventsForFeature(featureName: string): Promise { + return this.events.filter((e) => e.featureName === featureName); + } + store(event: IEvent): Promise { this.events.push(event); this.emit(event.type, event); diff --git a/website/docs/addons/datadog.md b/website/docs/addons/datadog.md index 1bcf21b055..247ac60d32 100644 --- a/website/docs/addons/datadog.md +++ b/website/docs/addons/datadog.md @@ -13,14 +13,23 @@ The Datadog addon will perform a single retry if the HTTP POST against the Datad #### Events {#events} -You can choose to trigger updates for the following events (we might add more event types in the future): +You can choose to trigger updates for the following events: - feature-created -- feature-updated +- feature-updated (*) +- feature-metadata-updated +- feature-project-change - feature-archived - feature-revived +- feature-strategy-update +- feature-strategy-add +- feature-strategy-remove - feature-stale-on - feature-stale-off +- feature-environment-enabled +- feature-environment-disabled + +> *) Deprecated, and will not be used after transition to environments in Unleash v4.3 #### Parameters {#parameters} diff --git a/website/docs/addons/slack.md b/website/docs/addons/slack.md index 9c473661e3..9085e0d595 100644 --- a/website/docs/addons/slack.md +++ b/website/docs/addons/slack.md @@ -13,14 +13,23 @@ The Slack addon will perform a single retry if the HTTP POST against the Slack W #### Events {#events} -You can choose to trigger updates for the following events (we might add more event types in the future): +You can choose to trigger updates for the following events: - feature-created -- feature-updated +- feature-updated (*) +- feature-metadata-updated +- feature-project-change - feature-archived - feature-revived +- feature-strategy-update +- feature-strategy-add +- feature-strategy-remove - feature-stale-on - feature-stale-off +- feature-environment-enabled +- feature-environment-disabled + +> *) Deprecated, and will not be used after transition to environments in Unleash v4.3 #### Parameters {#parameters} diff --git a/website/docs/addons/teams.md b/website/docs/addons/teams.md index 463f8d1d38..e1641887a7 100644 --- a/website/docs/addons/teams.md +++ b/website/docs/addons/teams.md @@ -13,14 +13,23 @@ The Microsoft Teams addon will perform a single retry if the HTTP POST against t #### Events {#events} -You can choose to trigger updates for the following events (we might add more event types in the future): +You can choose to trigger updates for the following events: - feature-created -- feature-updated +- feature-updated (*) +- feature-metadata-updated +- feature-project-change - feature-archived - feature-revived +- feature-strategy-update +- feature-strategy-add +- feature-strategy-remove - feature-stale-on - feature-stale-off +- feature-environment-enabled +- feature-environment-disabled + +> *) Deprecated, and will not be used after transition to environments in Unleash v4.3 #### Parameters {#parameters} diff --git a/website/docs/addons/webhook.md b/website/docs/addons/webhook.md index 7511341fa6..8d55989f36 100644 --- a/website/docs/addons/webhook.md +++ b/website/docs/addons/webhook.md @@ -16,13 +16,20 @@ The webhook will perform a single retry if the HTTP POST call fails (either a 50 You can choose to trigger updates for the following events (we might add more event types in the future): - feature-created -- feature-updated +- feature-updated (*) +- feature-metadata-updated +- feature-project-change - feature-archived - feature-revived +- feature-strategy-update +- feature-strategy-add +- feature-strategy-remove - feature-stale-on - feature-stale-off +- feature-environment-enabled +- feature-environment-disabled -(we will add more events in the future!) +> *) Deprecated, and will not be used after transition to environments in Unleash v4.3 #### Parameters {#parameters} diff --git a/website/docs/api/admin/events-api.md b/website/docs/api/admin/events-api.md index ae1cdde09b..6fd800f0c5 100644 --- a/website/docs/api/admin/events-api.md +++ b/website/docs/api/admin/events-api.md @@ -13,39 +13,208 @@ Used to fetch all changes in the unleash system. Defined event types: +### Feature Toggle events: + - feature-created +- feature-deleted - feature-updated +- feature-metadata-updated +- feature-project-change - feature-archived - feature-revived +- feature-import +- feature-tagged +- feature-tag-import +- feature-strategy-update +- feature-strategy-add +- feature-strategy-remove +- drop-feature-tags +- feature-untagged +- feature-stale-on +- feature-stale-off +- drop-features +- feature-environment-enabled +- feature-environment-disabled + +### Strategy Events + - strategy-created - strategy-deleted +- strategy-deprecated +- strategy-reactivated +- strategy-updated +- strategy-import +- drop-strategies + +### Context field events + +- context-field-created +- context-field-updated +- context-field-deleted + +### Project events + +- project-created +- project-updated +- project-deleted +- project-import +- drop-projects + +### Tag events + - tag-created - tag-deleted +- tag-import +- drop-tags + + +### Tag type events - tag-type-created -- tag-type-updated - tag-type-deleted -- application-created +- tag-type-updated +- tag-type-import +- drop-tag-types + + +### Addon events +- addon-config-created +- addon-config-updated +- addon-config-deleted + + +### User events +- user-created +- user-updated +- user-deleted + + +### Environment events (Enterprise) + +- drop-environments +- environment-import **Response** ```json { - "version": 1, - "events": [ - { - "id": 454, - "type": "feature-updated", - "createdBy": "unknown", - "createdAt": "2016-08-24T11:22:01.354Z", - "data": { - "name": "eid.bankid.mobile", - "description": "", - "strategy": "default", - "enabled": true, - "parameters": {} - }, - "diffs": [{ "kind": "E", "path": ["enabled"], "lhs": false, "rhs": true }] - } - ] + "version": 2, + "events": [{ + "id": 187, + "type": "feature-metadata-updated", + "createdBy": "admin", + "createdAt": "2021-11-11T09:42:14.271Z", + "data": { + "name": "HelloEvents!", + "description": "Hello Events update!", + "type": "release", + "project": "default", + "stale": false, + "variants": [], + "createdAt": "2021-11-11T09:40:51.077Z", + "lastSeenAt": null + }, + "preData": { + "name": "HelloEvents!", + "description": "Hello Events!", + "type": "release", + "project": "default", + "stale": false, + "variants": [], + "createdAt": "2021-11-11T09:40:51.077Z", + "lastSeenAt": null + }, + "tags": [{ + "value": "team-x", + "type": "simple" + }], + "featureName": "HelloEvents!", + "project": "default", + "environment": null + }, { + "id": 186, + "type": "feature-tagged", + "createdBy": "admin", + "createdAt": "2021-11-11T09:41:20.464Z", + "data": { + "type": "simple", + "value": "team-x" + }, + "preData": null, + "tags": [], + "featureName": "HelloEvents!", + "project": null, + "environment": null + }, { + "id": 184, + "type": "feature-environment-enabled", + "createdBy": "admin", + "createdAt": "2021-11-11T09:41:03.782Z", + "data": null, + "preData": null, + "tags": [], + "featureName": "HelloEvents!", + "project": "default", + "environment": "default" + }, { + "id": 183, + "type": "feature-strategy-add", + "createdBy": "admin", + "createdAt": "2021-11-11T09:41:00.740Z", + "data": { + "id": "88e1df00-1951-452f-a063-6f5e18476f87", + "name": "flexibleRollout", + "constraints": [], + "parameters": { + "groupId": "HelloEvents!", + "rollout": 51, + "stickiness": "default" + } + }, + "preData": null, + "tags": [], + "featureName": "HelloEvents!", + "project": "default", + "environment": "default" + }, { + "id": 182, + "type": "feature-created", + "createdBy": "admin", + "createdAt": "2021-11-11T09:40:51.083Z", + "data": { + "name": "HelloEvents!", + "description": "Hello Events!", + "type": "release", + "project": "default", + "stale": false, + "variants": [], + "createdAt": "2021-11-11T09:40:51.077Z", + "lastSeenAt": null + }, + "preData": null, + "tags": [], + "featureName": "HelloEvents!", + "project": "default", + "environment": null + }] } ``` + +All events will implement the following interface: + +```js + +interface IEvent { + id: number; + createdAt: Date; + type: string; + createdBy: string; + project?: string; + environment?: string; + featureName?: string; + data?: any; + preData?: any; + tags?: ITag[]; +} +``` + + diff --git a/yarn.lock b/yarn.lock index 25f4c80a92..3f4423c92e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2113,11 +2113,6 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-diff@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz" - integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== - deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz"