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,
);
};
3 changes: 2 additions & 1 deletion src/lib/routes/admin-api/project/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ export class ProjectApiTokenController extends Controller {
): Promise<void> {
const { user } = req;
const { projectId } = req.params;
await this.projectService.getProject(projectId); // Validates that the project exists

const project = await this.projectService.getProject(projectId); // Validates that the project exists
console.log('project', project);
const projectTokens = await this.accessibleTokens(user, projectId);
this.openApiService.respondWithValidation(
200,
Expand Down
18 changes: 12 additions & 6 deletions src/lib/routes/client-api/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@ import { createTestConfig } from '../../../test/config/test-config';
import { clientMetricsSchema } from '../../services/client-metrics/schema';
import { createServices } from '../../services';
import { IUnleashOptions, IUnleashServices, IUnleashStores } from '../../types';
import dbInit from '../../../test/e2e/helpers/database-init';

async function getSetup(opts?: IUnleashOptions) {
const stores = createStores();
let db;

async function getSetup(opts?: IUnleashOptions) {
const config = createTestConfig(opts);
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
db = await dbInit('metrics', config.getLogger);

const services = createServices(db.stores, config, db.rawDatabase);
const app = await getApp(config, db.stores, services);

return {
request: supertest(app),
stores,
stores: db.stores,
services,
destroy: () => {
destroy: async () => {
services.versionService.destroy();
services.clientInstanceService.destroy();
await db.destroy();
},
};
}
Expand Down Expand Up @@ -207,6 +211,8 @@ test('should set lastSeen on toggle', async () => {
await services.lastSeenService.store();
const toggle = await stores.featureToggleStore.get('toggleLastSeen');

console.log(toggle);

expect(toggle.lastSeenAt).toBeTruthy();
});

Expand Down
61 changes: 0 additions & 61 deletions src/lib/services/client-metrics/last-seen-service.test.ts

This file was deleted.

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
@@ -1,9 +1,9 @@
import { secondsToMilliseconds } from 'date-fns';
import { Logger } from '../../logger';
import { IUnleashConfig } from '../../server-impl';
import { IUnleashStores } from '../../types';
import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
import { Logger } from '../../../logger';
import { IUnleashConfig } from '../../../server-impl';
import { IClientMetricsEnv } from '../../../types/stores/client-metrics-store-v2';
import { ILastSeenStore } from './types/last-seen-store-type';
import { IFeatureToggleStore, IUnleashStores } from '../../../../lib/types';

export type LastSeenInput = {
featureName: string;
Expand All @@ -17,17 +17,26 @@ export class LastSeenService {

private logger: Logger;

private lastSeenStore: ILastSeenStore;

private featureToggleStore: IFeatureToggleStore;

private config: IUnleashConfig;

constructor(
{ featureToggleStore }: Pick<IUnleashStores, 'featureToggleStore'>,
{
featureToggleStore,
lastSeenStore,
}: Pick<IUnleashStores, 'featureToggleStore' | 'lastSeenStore'>,
config: IUnleashConfig,
lastSeenInterval = secondsToMilliseconds(30),
) {
this.lastSeenStore = lastSeenStore;
this.featureToggleStore = featureToggleStore;
this.logger = config.getLogger(
'/services/client-metrics/last-seen-service.ts',
);
this.config = config;

this.timers.push(
setInterval(() => this.store(), lastSeenInterval).unref(),
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this be part of the scheduler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will look into separating this in a follow-up

Expand All @@ -42,7 +51,12 @@ export class LastSeenService {
this.logger.debug(
`Updating last seen for ${lastSeenToggles.length} toggles`,
);
await this.featureToggleStore.setLastSeen(lastSeenToggles);

if (this.config.flagResolver.isEnabled('useLastSeenRefactor')) {
await this.lastSeenStore.setLastSeen(lastSeenToggles);
} else {
await this.featureToggleStore.setLastSeen(lastSeenToggles);
}
Comment on lines +55 to +59
Copy link
Contributor Author

Choose a reason for hiding this comment

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

maintain previous implementation

}
return count;
}
Expand Down
Loading
Loading