Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/last seen at by environment #4939

Merged
merged 16 commits into from
Oct 9, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ const setupRoutes = () => {
test('should show the count of projects and features used in', async () => {
setupRoutes();

render(
<ContextList />,
);
render(<ContextList />);

await screen.findByText('2 feature toggles');
await screen.findByText('3 projects');
Expand Down
2 changes: 2 additions & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,
"variantTypeNumber": false,
},
},
Expand Down Expand Up @@ -150,6 +151,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,
"variantTypeNumber": false,
},
"externalResolver": {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/feature-toggle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Logger, LogProvider } from '../logger';
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { Db } from './db';
import { LastSeenInput } from '../services/client-metrics/last-seen-service';
import { LastSeenInput } from '../services/client-metrics/last-seen/last-seen-service';
import { NameExistsError } from '../error';

export type EnvironmentFeatureNames = { [key: string]: string[] };
Expand Down
2 changes: 2 additions & 0 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Db } from './db';
import { ImportTogglesStore } from '../features/export-import-toggles/import-toggles-store';
import PrivateProjectStore from '../features/private-project/privateProjectStore';
import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store';
import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store';

export const createStores = (
config: IUnleashConfig,
Expand Down Expand Up @@ -132,6 +133,7 @@ export const createStores = (
importTogglesStore: new ImportTogglesStore(db),
privateProjectStore: new PrivateProjectStore(db, getLogger),
dependentFeaturesStore: new DependentFeaturesStore(db),
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
};
};

Expand Down
6 changes: 6 additions & 0 deletions src/lib/features/project/createProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import { LastSeenAtReadModel } from '../../services/client-metrics/last-seen/last-seen-read-model';
import { FakeLastSeenReadModel } from '../../services/client-metrics/last-seen/fake-last-seen-read-model';

export const createProjectService = (
db: Db,
Expand Down Expand Up @@ -99,6 +101,7 @@ export const createProjectService = (
);

const privateProjectChecker = createPrivateProjectChecker(db, config);
const lastSeenReadModel = new LastSeenAtReadModel(db);

return new ProjectService(
{
Expand All @@ -118,6 +121,7 @@ export const createProjectService = (
favoriteService,
eventService,
privateProjectChecker,
lastSeenReadModel,
);
};

Expand Down Expand Up @@ -160,6 +164,7 @@ export const createFakeProjectService = (
);

const privateProjectChecker = createFakePrivateProjectChecker();
const fakeLastSeenReadModel = new FakeLastSeenReadModel();

return new ProjectService(
{
Expand All @@ -179,5 +184,6 @@ export const createFakeProjectService = (
favoriteService,
eventService,
privateProjectChecker,
fakeLastSeenReadModel,
);
};
56 changes: 52 additions & 4 deletions src/lib/services/client-metrics/last-seen-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import createStores from '../../../test/fixtures/store';
import EventEmitter from 'events';
import getLogger from '../../../test/fixtures/no-logger';
import { IUnleashConfig } from '../../types';
import { LastSeenService } from './last-seen-service';
import { LastSeenService } from './last-seen/last-seen-service';

function initLastSeenService(flagEnabled = true) {
const stores = createStores();
Expand All @@ -20,13 +20,23 @@ function initLastSeenService(flagEnabled = true) {
},
} as unknown as IUnleashConfig;

const lastSeenService = new LastSeenService(stores, config);
const lastSeenService = new LastSeenService(
{
lastSeenStore: stores.lastSeenStore,
featureToggleStore: stores.featureToggleStore,
},
config,
);

return { lastSeenService, featureToggleStore: stores.featureToggleStore };
return {
lastSeenService,
featureToggleStore: stores.featureToggleStore,
lastSeenStore: stores.lastSeenStore,
};
}

test('should not add duplicates per feature/environment', async () => {
const { lastSeenService, featureToggleStore } = initLastSeenService();
const { lastSeenService, featureToggleStore } = initLastSeenService(false);

lastSeenService.updateLastSeen([
{
Expand Down Expand Up @@ -59,3 +69,41 @@ test('should not add duplicates per feature/environment', async () => {
},
]);
});

test('should call last seen at store with correct data', async () => {
const { lastSeenService, lastSeenStore, featureToggleStore } =
initLastSeenService(true);

lastSeenService.updateLastSeen([
{
featureName: 'myFeature',
environment: 'development',
yes: 1,
no: 0,
appName: 'test',
timestamp: new Date(),
},
]);

lastSeenService.updateLastSeen([
{
featureName: 'myFeature',
environment: 'development',
yes: 1,
no: 0,
appName: 'test',
timestamp: new Date(),
},
]);
lastSeenStore.setLastSeen = jest.fn();
featureToggleStore.setLastSeen = jest.fn();
await lastSeenService.store();

expect(lastSeenStore.setLastSeen).toHaveBeenCalledWith([
{
environment: 'development',
featureName: 'myFeature',
},
]);
expect(featureToggleStore.setLastSeen).toHaveBeenCalledTimes(0);
});
34 changes: 34 additions & 0 deletions src/lib/services/client-metrics/last-seen/createLastSeenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import FakeFeatureToggleStore from '../../../../test/fixtures/fake-feature-toggle-store';
import FeatureToggleStore from '../../../db/feature-toggle-store';
import { Db, IUnleashConfig } from '../../../server-impl';
import { FakeLastSeenStore } from './fake-last-seen-store';
import { LastSeenService } from './last-seen-service';
import LastSeenStore from './last-seen-store';

export const createLastSeenService = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use composite root pattern for LastSeenService

db: Db,
config: IUnleashConfig,
): LastSeenService => {
const lastSeenStore = new LastSeenStore(
db,
config.eventBus,
config.getLogger,
);

const featureToggleStore = new FeatureToggleStore(
db,
config.eventBus,
config.getLogger,
);

return new LastSeenService({ lastSeenStore, featureToggleStore }, config);
};

export const createFakeLastSeenService = (
config: IUnleashConfig,
): LastSeenService => {
const lastSeenStore = new FakeLastSeenStore();
const featureToggleStore = new FakeFeatureToggleStore();

return new LastSeenService({ lastSeenStore, featureToggleStore }, config);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IFeatureLastSeenResults } from './last-seen-read-model';
import { ILastSeenReadModel } from './types/last-seen-read-model-type';

export class FakeLastSeenReadModel implements ILastSeenReadModel {
// eslint-disable-next-line
getForFeature(features: string[]): Promise<IFeatureLastSeenResults> {
return Promise.resolve({});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LastSeenInput } from './last-seen-service';
import { ILastSeenStore } from './types/last-seen-store-type';

export class FakeLastSeenStore implements ILastSeenStore {
setLastSeen(data: LastSeenInput[]): Promise<void> {
data.map((lastSeen) => lastSeen);
return Promise.resolve();
}
}
39 changes: 39 additions & 0 deletions src/lib/services/client-metrics/last-seen/last-seen-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Logger } from '../../../logger';
import { IFeatureOverview } from '../../../types';
import { IFeatureLastSeenResults } from './last-seen-read-model';

export class LastSeenMapper {
mapToFeatures(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Map results of reading into environments of incoming features to maintain todays data structure, might change in follow up iteration

features: IFeatureOverview[],
lastSeenAtPerEnvironment: IFeatureLastSeenResults,
logger: Logger,
): IFeatureOverview[] {
return features.map((feature) => {
if (!feature.environments) {
logger.warn('Feature without environments:', feature);
return feature;
}

feature.environments = feature.environments.map((environment) => {
const noData =
!lastSeenAtPerEnvironment[feature.name] ||
!lastSeenAtPerEnvironment[feature.name][environment.name];

if (noData) {
logger.warn(
'No last seen data for environment:',
environment,
);
return environment;
}

environment.lastSeenAt = new Date(
lastSeenAtPerEnvironment[feature.name][environment.name]
.lastSeen,
);
return environment;
});
return feature;
});
}
}
41 changes: 41 additions & 0 deletions src/lib/services/client-metrics/last-seen/last-seen-read-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Db } from '../../../db/db';
import { ILastSeenReadModel } from './types/last-seen-read-model-type';

const TABLE = 'last_seen_at_metrics';

export interface IFeatureLastSeenResults {
[featureName: string]: {
[environment: string]: {
lastSeen: string;
};
};
}
export class LastSeenAtReadModel implements ILastSeenReadModel {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retrieve last seen at results per environment for an array of features

private db: Db;

constructor(db: Db) {
this.db = db;
}

async getForFeature(features: string[]): Promise<IFeatureLastSeenResults> {
const rows = await this.db(TABLE).whereIn('feature_name', features);

const result = rows.reduce((acc, curr) => {
if (!acc[curr.feature_name]) {
acc[curr.feature_name] = {};

acc[curr.feature_name][curr.environment] = {
lastSeen: curr.last_seen_at,
};
} else {
acc[curr.feature_name][curr.environment] = {
lastSeen: curr.last_seen_at,
};
}

return acc;
}, {});

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import createStores from '../../../../test/fixtures/store';
import EventEmitter from 'events';
import getLogger from '../../../../test/fixtures/no-logger';
import { IUnleashConfig } from '../../../types';

import { createFakeLastSeenService } from './createLastSeenService';

function initLastSeenService(flagEnabled = true) {
const stores = createStores();

const eventBus = new EventEmitter();
eventBus.emit = jest.fn();

const config = {
eventBus,
getLogger,
flagResolver: {
isEnabled: () => {
return flagEnabled;
},
},
} as unknown as IUnleashConfig;

const lastSeenService = createFakeLastSeenService(config);

return { lastSeenService, featureToggleStore: stores.featureToggleStore };
}

test('should not add duplicates per feature/environment', async () => {
const { lastSeenService, featureToggleStore } = initLastSeenService();

lastSeenService.updateLastSeen([
{
featureName: 'myFeature',
environment: 'development',
yes: 1,
no: 0,
appName: 'test',
timestamp: new Date(),
},
]);

lastSeenService.updateLastSeen([
{
featureName: 'myFeature',
environment: 'development',
yes: 1,
no: 0,
appName: 'test',
timestamp: new Date(),
},
]);
featureToggleStore.setLastSeen = jest.fn();
await lastSeenService.store();

expect(featureToggleStore.setLastSeen).toHaveBeenCalledWith([
{
environment: 'development',
featureName: 'myFeature',
},
]);
});
Loading