mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-19 17:52:45 +02:00
Fix/last seen at by environment (#4939)
Initial architecture for last seen at by environment.
This commit is contained in:
parent
34fc17146e
commit
d896dbd0c7
@ -110,6 +110,7 @@ exports[`should create default config 1`] = `
|
|||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
"transactionalDecorator": false,
|
"transactionalDecorator": false,
|
||||||
|
"useLastSeenRefactor": false,
|
||||||
"variantTypeNumber": false,
|
"variantTypeNumber": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -150,6 +151,7 @@ exports[`should create default config 1`] = `
|
|||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
"transactionalDecorator": false,
|
"transactionalDecorator": false,
|
||||||
|
"useLastSeenRefactor": false,
|
||||||
"variantTypeNumber": false,
|
"variantTypeNumber": false,
|
||||||
},
|
},
|
||||||
"externalResolver": {
|
"externalResolver": {
|
||||||
|
@ -7,7 +7,7 @@ import { Logger, LogProvider } from '../logger';
|
|||||||
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
|
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
|
||||||
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
||||||
import { Db } from './db';
|
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';
|
import { NameExistsError } from '../error';
|
||||||
|
|
||||||
export type EnvironmentFeatureNames = { [key: string]: string[] };
|
export type EnvironmentFeatureNames = { [key: string]: string[] };
|
||||||
|
@ -38,6 +38,7 @@ import { Db } from './db';
|
|||||||
import { ImportTogglesStore } from '../features/export-import-toggles/import-toggles-store';
|
import { ImportTogglesStore } from '../features/export-import-toggles/import-toggles-store';
|
||||||
import PrivateProjectStore from '../features/private-project/privateProjectStore';
|
import PrivateProjectStore from '../features/private-project/privateProjectStore';
|
||||||
import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store';
|
import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store';
|
||||||
|
import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -132,6 +133,7 @@ export const createStores = (
|
|||||||
importTogglesStore: new ImportTogglesStore(db),
|
importTogglesStore: new ImportTogglesStore(db),
|
||||||
privateProjectStore: new PrivateProjectStore(db, getLogger),
|
privateProjectStore: new PrivateProjectStore(db, getLogger),
|
||||||
dependentFeaturesStore: new DependentFeaturesStore(db),
|
dependentFeaturesStore: new DependentFeaturesStore(db),
|
||||||
|
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -41,6 +41,8 @@ import {
|
|||||||
createPrivateProjectChecker,
|
createPrivateProjectChecker,
|
||||||
} from '../private-project/createPrivateProjectChecker';
|
} from '../private-project/createPrivateProjectChecker';
|
||||||
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
|
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 = (
|
export const createProjectService = (
|
||||||
db: Db,
|
db: Db,
|
||||||
@ -99,6 +101,7 @@ export const createProjectService = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const privateProjectChecker = createPrivateProjectChecker(db, config);
|
const privateProjectChecker = createPrivateProjectChecker(db, config);
|
||||||
|
const lastSeenReadModel = new LastSeenAtReadModel(db);
|
||||||
|
|
||||||
return new ProjectService(
|
return new ProjectService(
|
||||||
{
|
{
|
||||||
@ -118,6 +121,7 @@ export const createProjectService = (
|
|||||||
favoriteService,
|
favoriteService,
|
||||||
eventService,
|
eventService,
|
||||||
privateProjectChecker,
|
privateProjectChecker,
|
||||||
|
lastSeenReadModel,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -160,6 +164,7 @@ export const createFakeProjectService = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const privateProjectChecker = createFakePrivateProjectChecker();
|
const privateProjectChecker = createFakePrivateProjectChecker();
|
||||||
|
const fakeLastSeenReadModel = new FakeLastSeenReadModel();
|
||||||
|
|
||||||
return new ProjectService(
|
return new ProjectService(
|
||||||
{
|
{
|
||||||
@ -179,5 +184,6 @@ export const createFakeProjectService = (
|
|||||||
favoriteService,
|
favoriteService,
|
||||||
eventService,
|
eventService,
|
||||||
privateProjectChecker,
|
privateProjectChecker,
|
||||||
|
fakeLastSeenReadModel,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -149,8 +149,9 @@ export class ProjectApiTokenController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
const { projectId } = req.params;
|
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);
|
const projectTokens = await this.accessibleTokens(user, projectId);
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
|
@ -5,21 +5,25 @@ import { createTestConfig } from '../../../test/config/test-config';
|
|||||||
import { clientMetricsSchema } from '../../services/client-metrics/schema';
|
import { clientMetricsSchema } from '../../services/client-metrics/schema';
|
||||||
import { createServices } from '../../services';
|
import { createServices } from '../../services';
|
||||||
import { IUnleashOptions, IUnleashServices, IUnleashStores } from '../../types';
|
import { IUnleashOptions, IUnleashServices, IUnleashStores } from '../../types';
|
||||||
|
import dbInit from '../../../test/e2e/helpers/database-init';
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
async function getSetup(opts?: IUnleashOptions) {
|
async function getSetup(opts?: IUnleashOptions) {
|
||||||
const stores = createStores();
|
|
||||||
|
|
||||||
const config = createTestConfig(opts);
|
const config = createTestConfig(opts);
|
||||||
const services = createServices(stores, config);
|
db = await dbInit('metrics', config.getLogger);
|
||||||
const app = await getApp(config, stores, services);
|
|
||||||
|
const services = createServices(db.stores, config, db.rawDatabase);
|
||||||
|
const app = await getApp(config, db.stores, services);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request: supertest(app),
|
request: supertest(app),
|
||||||
stores,
|
stores: db.stores,
|
||||||
services,
|
services,
|
||||||
destroy: () => {
|
destroy: async () => {
|
||||||
services.versionService.destroy();
|
services.versionService.destroy();
|
||||||
services.clientInstanceService.destroy();
|
services.clientInstanceService.destroy();
|
||||||
|
await db.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -207,6 +211,8 @@ test('should set lastSeen on toggle', async () => {
|
|||||||
await services.lastSeenService.store();
|
await services.lastSeenService.store();
|
||||||
const toggle = await stores.featureToggleStore.get('toggleLastSeen');
|
const toggle = await stores.featureToggleStore.get('toggleLastSeen');
|
||||||
|
|
||||||
|
console.log(toggle);
|
||||||
|
|
||||||
expect(toggle.lastSeenAt).toBeTruthy();
|
expect(toggle.lastSeenAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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 = new LastSeenService(stores, 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',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
@ -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 = (
|
||||||
|
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);
|
||||||
|
};
|
@ -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({});
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Logger } from '../../../logger';
|
||||||
|
import { IFeatureOverview } from '../../../types';
|
||||||
|
import { IFeatureLastSeenResults } from './last-seen-read-model';
|
||||||
|
|
||||||
|
export class LastSeenMapper {
|
||||||
|
mapToFeatures(
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import { secondsToMilliseconds } from 'date-fns';
|
import { secondsToMilliseconds } from 'date-fns';
|
||||||
import { Logger } from '../../logger';
|
import { Logger } from '../../../logger';
|
||||||
import { IUnleashConfig } from '../../server-impl';
|
import { IUnleashConfig } from '../../../server-impl';
|
||||||
import { IUnleashStores } from '../../types';
|
import { IClientMetricsEnv } from '../../../types/stores/client-metrics-store-v2';
|
||||||
import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2';
|
import { ILastSeenStore } from './types/last-seen-store-type';
|
||||||
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
|
import { IFeatureToggleStore, IUnleashStores } from '../../../../lib/types';
|
||||||
|
|
||||||
export type LastSeenInput = {
|
export type LastSeenInput = {
|
||||||
featureName: string;
|
featureName: string;
|
||||||
@ -17,17 +17,26 @@ export class LastSeenService {
|
|||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
|
private lastSeenStore: ILastSeenStore;
|
||||||
|
|
||||||
private featureToggleStore: IFeatureToggleStore;
|
private featureToggleStore: IFeatureToggleStore;
|
||||||
|
|
||||||
|
private config: IUnleashConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{ featureToggleStore }: Pick<IUnleashStores, 'featureToggleStore'>,
|
{
|
||||||
|
featureToggleStore,
|
||||||
|
lastSeenStore,
|
||||||
|
}: Pick<IUnleashStores, 'featureToggleStore' | 'lastSeenStore'>,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
lastSeenInterval = secondsToMilliseconds(30),
|
lastSeenInterval = secondsToMilliseconds(30),
|
||||||
) {
|
) {
|
||||||
|
this.lastSeenStore = lastSeenStore;
|
||||||
this.featureToggleStore = featureToggleStore;
|
this.featureToggleStore = featureToggleStore;
|
||||||
this.logger = config.getLogger(
|
this.logger = config.getLogger(
|
||||||
'/services/client-metrics/last-seen-service.ts',
|
'/services/client-metrics/last-seen-service.ts',
|
||||||
);
|
);
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
this.timers.push(
|
this.timers.push(
|
||||||
setInterval(() => this.store(), lastSeenInterval).unref(),
|
setInterval(() => this.store(), lastSeenInterval).unref(),
|
||||||
@ -42,8 +51,13 @@ export class LastSeenService {
|
|||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating last seen for ${lastSeenToggles.length} toggles`,
|
`Updating last seen for ${lastSeenToggles.length} toggles`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.config.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||||
|
await this.lastSeenStore.setLastSeen(lastSeenToggles);
|
||||||
|
} else {
|
||||||
await this.featureToggleStore.setLastSeen(lastSeenToggles);
|
await this.featureToggleStore.setLastSeen(lastSeenToggles);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
63
src/lib/services/client-metrics/last-seen/last-seen-store.ts
Normal file
63
src/lib/services/client-metrics/last-seen/last-seen-store.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
import { LogProvider, Logger } from '../../../logger';
|
||||||
|
import { DB_TIME } from '../../../metric-events';
|
||||||
|
import { Db } from '../../../server-impl';
|
||||||
|
import metricsHelper from '../../..//util/metrics-helper';
|
||||||
|
import { LastSeenInput } from './last-seen-service';
|
||||||
|
import { ILastSeenStore } from './types/last-seen-store-type';
|
||||||
|
|
||||||
|
const TABLE = 'last_seen_at_metrics';
|
||||||
|
|
||||||
|
export interface FeaturesTable {
|
||||||
|
feature_name: string;
|
||||||
|
last_seen_at: Date;
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LastSeenStore implements ILastSeenStore {
|
||||||
|
private db: Db;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private timer: Function;
|
||||||
|
|
||||||
|
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = getLogger('last-seen-store.ts');
|
||||||
|
this.timer = (action) =>
|
||||||
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||||
|
store: 'last-seen-environment-store',
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLastSeen(data: LastSeenInput[]): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserts = data.map((item) => {
|
||||||
|
return {
|
||||||
|
feature_name: item.featureName,
|
||||||
|
environment: item.environment,
|
||||||
|
last_seen_at: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchSize = 1000;
|
||||||
|
|
||||||
|
for (let i = 0; i < inserts.length; i += batchSize) {
|
||||||
|
const batch = inserts.slice(i, i + batchSize);
|
||||||
|
// Knex optimizes multi row insert when given an array:
|
||||||
|
// https://knexjs.org/guide/query-builder.html#insert
|
||||||
|
await this.db(TABLE)
|
||||||
|
.insert(batch)
|
||||||
|
.onConflict(['feature_name', 'environment'])
|
||||||
|
.merge();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Could not update lastSeen, error: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LastSeenStore;
|
@ -0,0 +1,95 @@
|
|||||||
|
import { IFeatureOverview } from '../../../../types';
|
||||||
|
import { LastSeenMapper } from '../last-seen-mapper';
|
||||||
|
import getLogger from '../../../../../test/fixtures/no-logger';
|
||||||
|
|
||||||
|
test('should produce correct output when mapped', () => {
|
||||||
|
const mapper = new LastSeenMapper();
|
||||||
|
|
||||||
|
const inputLastSeen = {
|
||||||
|
exp: {
|
||||||
|
production: { lastSeen: '2023-10-05T07:27:04.286Z' },
|
||||||
|
development: { lastSeen: '2023-10-04T19:03:29.682Z' },
|
||||||
|
},
|
||||||
|
'payment-system': {
|
||||||
|
production: { lastSeen: '2023-10-05T07:27:04.286Z' },
|
||||||
|
development: { lastSeen: '2023-10-04T19:03:29.682Z' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputFeatures: IFeatureOverview[] = [
|
||||||
|
{
|
||||||
|
type: 'release',
|
||||||
|
description: null,
|
||||||
|
favorite: false,
|
||||||
|
name: 'payment-system',
|
||||||
|
// @ts-ignore
|
||||||
|
createdAt: '2023-06-30T12:57:20.476Z',
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-03T13:08:16.263Z',
|
||||||
|
stale: false,
|
||||||
|
impressionData: false,
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'development',
|
||||||
|
enabled: false,
|
||||||
|
type: 'development',
|
||||||
|
sortOrder: 2,
|
||||||
|
variantCount: 0,
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-04T19:03:29.682Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
enabled: true,
|
||||||
|
type: 'production',
|
||||||
|
sortOrder: 3,
|
||||||
|
variantCount: 0,
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-05T07:27:04.286Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'experiment',
|
||||||
|
description: null,
|
||||||
|
favorite: false,
|
||||||
|
name: 'exp',
|
||||||
|
// @ts-ignore
|
||||||
|
createdAt: '2023-09-13T08:08:28.211Z',
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-03T13:08:16.263Z',
|
||||||
|
stale: false,
|
||||||
|
impressionData: false,
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'development',
|
||||||
|
enabled: false,
|
||||||
|
type: 'development',
|
||||||
|
sortOrder: 2,
|
||||||
|
variantCount: 0,
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-04T19:03:29.682Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
enabled: true,
|
||||||
|
type: 'production',
|
||||||
|
sortOrder: 3,
|
||||||
|
variantCount: 0,
|
||||||
|
// @ts-ignore
|
||||||
|
lastSeenAt: '2023-10-05T07:27:04.286Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
const result = mapper.mapToFeatures(inputFeatures, inputLastSeen, logger);
|
||||||
|
|
||||||
|
expect(result[0].environments[0].name).toBe('development');
|
||||||
|
expect(result[0].name).toBe('payment-system');
|
||||||
|
expect(result[0].environments[0].lastSeenAt).toEqual(
|
||||||
|
new Date(inputLastSeen['payment-system'].development.lastSeen),
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,109 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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 = new LastSeenService(
|
||||||
|
{
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastSeenService,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should not add duplicates per feature/environment', async () => {
|
||||||
|
const { lastSeenService, featureToggleStore } = initLastSeenService(false);
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
@ -0,0 +1,5 @@
|
|||||||
|
import { IFeatureLastSeenResults } from '../last-seen-read-model';
|
||||||
|
|
||||||
|
export interface ILastSeenReadModel {
|
||||||
|
getForFeature(features: string[]): Promise<IFeatureLastSeenResults>;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { LastSeenInput } from '../last-seen-service';
|
||||||
|
|
||||||
|
export interface ILastSeenStore {
|
||||||
|
setLastSeen(data: LastSeenInput[]): Promise<void>;
|
||||||
|
}
|
@ -4,7 +4,7 @@ import getLogger from '../../../test/fixtures/no-logger';
|
|||||||
|
|
||||||
import createStores from '../../../test/fixtures/store';
|
import createStores from '../../../test/fixtures/store';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { LastSeenService } from './last-seen-service';
|
import { LastSeenService } from './last-seen/last-seen-service';
|
||||||
import { IUnleashConfig } from 'lib/types';
|
import { IUnleashConfig } from 'lib/types';
|
||||||
|
|
||||||
function initClientMetrics(flagEnabled = true) {
|
function initClientMetrics(flagEnabled = true) {
|
||||||
@ -23,7 +23,13 @@ function initClientMetrics(flagEnabled = true) {
|
|||||||
},
|
},
|
||||||
} as unknown as IUnleashConfig;
|
} as unknown as IUnleashConfig;
|
||||||
|
|
||||||
const lastSeenService = new LastSeenService(stores, config);
|
const lastSeenService = new LastSeenService(
|
||||||
|
{
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
lastSeenService.updateLastSeen = jest.fn();
|
lastSeenService.updateLastSeen = jest.fn();
|
||||||
|
|
||||||
const service = new ClientMetricsServiceV2(stores, config, lastSeenService);
|
const service = new ClientMetricsServiceV2(stores, config, lastSeenService);
|
||||||
|
@ -17,7 +17,7 @@ import ApiUser from '../../types/api-user';
|
|||||||
import { ALL } from '../../types/models/api-token';
|
import { ALL } from '../../types/models/api-token';
|
||||||
import User from '../../types/user';
|
import User from '../../types/user';
|
||||||
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
|
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
|
||||||
import { LastSeenService } from './last-seen-service';
|
import { LastSeenService } from './last-seen/last-seen-service';
|
||||||
import { generateHourBuckets } from '../../util/time-utils';
|
import { generateHourBuckets } from '../../util/time-utils';
|
||||||
import { ClientMetricsSchema } from 'lib/openapi';
|
import { ClientMetricsSchema } from 'lib/openapi';
|
||||||
import { nameSchema } from '../../schema/feature-schema';
|
import { nameSchema } from '../../schema/feature-schema';
|
||||||
|
@ -35,7 +35,7 @@ import { ProxyService } from './proxy-service';
|
|||||||
import EdgeService from './edge-service';
|
import EdgeService from './edge-service';
|
||||||
import PatService from './pat-service';
|
import PatService from './pat-service';
|
||||||
import { PublicSignupTokenService } from './public-signup-token-service';
|
import { PublicSignupTokenService } from './public-signup-token-service';
|
||||||
import { LastSeenService } from './client-metrics/last-seen-service';
|
import { LastSeenService } from './client-metrics/last-seen/last-seen-service';
|
||||||
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
import MaintenanceService from './maintenance-service';
|
import MaintenanceService from './maintenance-service';
|
||||||
@ -59,7 +59,11 @@ import {
|
|||||||
createFakeChangeRequestAccessService,
|
createFakeChangeRequestAccessService,
|
||||||
} from '../features/change-request-access-service/createChangeRequestAccessReadModel';
|
} from '../features/change-request-access-service/createChangeRequestAccessReadModel';
|
||||||
import ConfigurationRevisionService from '../features/feature-toggle/configuration-revision-service';
|
import ConfigurationRevisionService from '../features/feature-toggle/configuration-revision-service';
|
||||||
import { createFeatureToggleService } from '../features';
|
import {
|
||||||
|
createFakeProjectService,
|
||||||
|
createFeatureToggleService,
|
||||||
|
createProjectService,
|
||||||
|
} from '../features';
|
||||||
import EventAnnouncerService from './event-announcer-service';
|
import EventAnnouncerService from './event-announcer-service';
|
||||||
import { createGroupService } from '../features/group/createGroupService';
|
import { createGroupService } from '../features/group/createGroupService';
|
||||||
import {
|
import {
|
||||||
@ -77,6 +81,10 @@ import {
|
|||||||
} from '../features/dependent-features/createDependentFeaturesService';
|
} from '../features/dependent-features/createDependentFeaturesService';
|
||||||
import { DependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model';
|
import { DependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model';
|
||||||
import { FakeDependentFeaturesReadModel } from '../features/dependent-features/fake-dependent-features-read-model';
|
import { FakeDependentFeaturesReadModel } from '../features/dependent-features/fake-dependent-features-read-model';
|
||||||
|
import {
|
||||||
|
createFakeLastSeenService,
|
||||||
|
createLastSeenService,
|
||||||
|
} from './client-metrics/last-seen/createLastSeenService';
|
||||||
|
|
||||||
// TODO: will be moved to scheduler feature directory
|
// TODO: will be moved to scheduler feature directory
|
||||||
export const scheduleServices = async (
|
export const scheduleServices = async (
|
||||||
@ -171,7 +179,9 @@ export const createServices = (
|
|||||||
const groupService = new GroupService(stores, config, eventService);
|
const groupService = new GroupService(stores, config, eventService);
|
||||||
const accessService = new AccessService(stores, config, groupService);
|
const accessService = new AccessService(stores, config, groupService);
|
||||||
const apiTokenService = new ApiTokenService(stores, config, eventService);
|
const apiTokenService = new ApiTokenService(stores, config, eventService);
|
||||||
const lastSeenService = new LastSeenService(stores, config);
|
const lastSeenService = db
|
||||||
|
? createLastSeenService(db, config)
|
||||||
|
: createFakeLastSeenService(config);
|
||||||
const clientMetricsServiceV2 = new ClientMetricsServiceV2(
|
const clientMetricsServiceV2 = new ClientMetricsServiceV2(
|
||||||
stores,
|
stores,
|
||||||
config,
|
config,
|
||||||
@ -260,16 +270,10 @@ export const createServices = (
|
|||||||
eventService,
|
eventService,
|
||||||
);
|
);
|
||||||
const favoritesService = new FavoritesService(stores, config, eventService);
|
const favoritesService = new FavoritesService(stores, config, eventService);
|
||||||
const projectService = new ProjectService(
|
const projectService = db
|
||||||
stores,
|
? createProjectService(db, config)
|
||||||
config,
|
: createFakeProjectService(config);
|
||||||
accessService,
|
|
||||||
featureToggleServiceV2,
|
|
||||||
groupService,
|
|
||||||
favoritesService,
|
|
||||||
eventService,
|
|
||||||
privateProjectChecker,
|
|
||||||
);
|
|
||||||
const projectHealthService = new ProjectHealthService(
|
const projectHealthService = new ProjectHealthService(
|
||||||
stores,
|
stores,
|
||||||
config,
|
config,
|
||||||
|
@ -65,6 +65,8 @@ import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
|||||||
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
||||||
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
|
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
|
||||||
import EventService from './event-service';
|
import EventService from './event-service';
|
||||||
|
import { ILastSeenReadModel } from './client-metrics/last-seen/types/last-seen-read-model-type';
|
||||||
|
import { LastSeenMapper } from './client-metrics/last-seen/last-seen-mapper';
|
||||||
|
|
||||||
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
||||||
|
|
||||||
@ -118,6 +120,8 @@ export default class ProjectService {
|
|||||||
|
|
||||||
private projectStatsStore: IProjectStatsStore;
|
private projectStatsStore: IProjectStatsStore;
|
||||||
|
|
||||||
|
private lastSeenReadModel: ILastSeenReadModel;
|
||||||
|
|
||||||
private flagResolver: IFlagResolver;
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private isEnterprise: boolean;
|
private isEnterprise: boolean;
|
||||||
@ -150,6 +154,7 @@ export default class ProjectService {
|
|||||||
favoriteService: FavoritesService,
|
favoriteService: FavoritesService,
|
||||||
eventService: EventService,
|
eventService: EventService,
|
||||||
privateProjectChecker: IPrivateProjectChecker,
|
privateProjectChecker: IPrivateProjectChecker,
|
||||||
|
lastSeenReadModel: ILastSeenReadModel,
|
||||||
) {
|
) {
|
||||||
this.projectStore = projectStore;
|
this.projectStore = projectStore;
|
||||||
this.environmentStore = environmentStore;
|
this.environmentStore = environmentStore;
|
||||||
@ -165,6 +170,7 @@ export default class ProjectService {
|
|||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.eventService = eventService;
|
this.eventService = eventService;
|
||||||
this.projectStatsStore = projectStatsStore;
|
this.projectStatsStore = projectStatsStore;
|
||||||
|
this.lastSeenReadModel = lastSeenReadModel;
|
||||||
this.logger = config.getLogger('services/project-service.js');
|
this.logger = config.getLogger('services/project-service.js');
|
||||||
this.flagResolver = config.flagResolver;
|
this.flagResolver = config.flagResolver;
|
||||||
this.isEnterprise = config.isEnterprise;
|
this.isEnterprise = config.isEnterprise;
|
||||||
@ -1071,6 +1077,22 @@ export default class ProjectService {
|
|||||||
this.projectStatsStore.getProjectStats(projectId),
|
this.projectStatsStore.getProjectStats(projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let decoratedFeatures = features;
|
||||||
|
|
||||||
|
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||||
|
const mapper = new LastSeenMapper();
|
||||||
|
|
||||||
|
const featureNames = features.map((feature) => feature.name);
|
||||||
|
const lastSeenAtPerEnvironment =
|
||||||
|
await this.lastSeenReadModel.getForFeature(featureNames);
|
||||||
|
|
||||||
|
decoratedFeatures = mapper.mapToFeatures(
|
||||||
|
decoratedFeatures,
|
||||||
|
lastSeenAtPerEnvironment,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats: projectStats,
|
stats: projectStats,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@ -1084,7 +1106,7 @@ export default class ProjectService {
|
|||||||
updatedAt: project.updatedAt,
|
updatedAt: project.updatedAt,
|
||||||
createdAt: project.createdAt,
|
createdAt: project.createdAt,
|
||||||
environments,
|
environments,
|
||||||
features,
|
features: decoratedFeatures,
|
||||||
members,
|
members,
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
@ -32,7 +32,8 @@ export type IFlagKey =
|
|||||||
| 'dependentFeatures'
|
| 'dependentFeatures'
|
||||||
| 'datadogJsonTemplate'
|
| 'datadogJsonTemplate'
|
||||||
| 'disableMetrics'
|
| 'disableMetrics'
|
||||||
| 'transactionalDecorator';
|
| 'transactionalDecorator'
|
||||||
|
| 'useLastSeenRefactor';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -152,6 +153,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_TRANSACTIONAL_DECORATOR,
|
process.env.UNLEASH_EXPERIMENTAL_TRANSACTIONAL_DECORATOR,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
useLastSeenRefactor: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_USE_LAST_SEEN_REFACTOR,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -32,7 +32,7 @@ import { ProxyService } from '../services/proxy-service';
|
|||||||
import EdgeService from '../services/edge-service';
|
import EdgeService from '../services/edge-service';
|
||||||
import PatService from '../services/pat-service';
|
import PatService from '../services/pat-service';
|
||||||
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
||||||
import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
import { LastSeenService } from '../services/client-metrics/last-seen/last-seen-service';
|
||||||
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||||
import { FavoritesService } from '../services/favorites-service';
|
import { FavoritesService } from '../services/favorites-service';
|
||||||
import MaintenanceService from '../services/maintenance-service';
|
import MaintenanceService from '../services/maintenance-service';
|
||||||
|
@ -35,6 +35,7 @@ import { IProjectStatsStore } from './stores/project-stats-store-type';
|
|||||||
import { IImportTogglesStore } from '../features/export-import-toggles/import-toggles-store-type';
|
import { IImportTogglesStore } from '../features/export-import-toggles/import-toggles-store-type';
|
||||||
import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType';
|
import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType';
|
||||||
import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type';
|
import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type';
|
||||||
|
import { ILastSeenStore } from '../services/client-metrics/last-seen/types/last-seen-store-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -74,6 +75,7 @@ export interface IUnleashStores {
|
|||||||
importTogglesStore: IImportTogglesStore;
|
importTogglesStore: IImportTogglesStore;
|
||||||
privateProjectStore: IPrivateProjectStore;
|
privateProjectStore: IPrivateProjectStore;
|
||||||
dependentFeaturesStore: IDependentFeaturesStore;
|
dependentFeaturesStore: IDependentFeaturesStore;
|
||||||
|
lastSeenStore: ILastSeenStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -113,4 +115,5 @@ export {
|
|||||||
IImportTogglesStore,
|
IImportTogglesStore,
|
||||||
IPrivateProjectStore,
|
IPrivateProjectStore,
|
||||||
IDependentFeaturesStore,
|
IDependentFeaturesStore,
|
||||||
|
ILastSeenStore,
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { LastSeenInput } from '../../services/client-metrics/last-seen-service';
|
import { LastSeenInput } from '../../services/client-metrics/last-seen/last-seen-service';
|
||||||
|
|
||||||
export interface IFeatureToggleQuery {
|
export interface IFeatureToggleQuery {
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
26
src/migrations/20231003113443-last-seen-at-metrics-table.js
Normal file
26
src/migrations/20231003113443-last-seen-at-metrics-table.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE last_seen_at_metrics (
|
||||||
|
feature_name VARCHAR(255),
|
||||||
|
environment VARCHAR(100),
|
||||||
|
last_seen_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (feature_name, environment),
|
||||||
|
FOREIGN KEY (environment) REFERENCES environments(name) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_feature_name
|
||||||
|
ON last_seen_at_metrics (feature_name);`,
|
||||||
|
callback(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`DROP TABLE last_seen_at_metrics;
|
||||||
|
`,
|
||||||
|
callback(),
|
||||||
|
);
|
||||||
|
};
|
@ -46,6 +46,7 @@ process.nextTick(async () => {
|
|||||||
datadogJsonTemplate: true,
|
datadogJsonTemplate: true,
|
||||||
dependentFeatures: true,
|
dependentFeatures: true,
|
||||||
transactionalDecorator: true,
|
transactionalDecorator: true,
|
||||||
|
useLastSeenRefactor: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -206,7 +206,12 @@ test('A role with only CREATE_PROJECT_API_TOKEN can create project tokens', asyn
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
|
const { request, destroy } = await setupAppWithCustomAuth(
|
||||||
|
stores,
|
||||||
|
preHook,
|
||||||
|
{},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
|
|
||||||
await request
|
await request
|
||||||
.post('/api/admin/projects/default/api-tokens')
|
.post('/api/admin/projects/default/api-tokens')
|
||||||
|
@ -10,13 +10,17 @@ let app;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('token_api_serial', getLogger);
|
db = await dbInit('token_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -81,13 +81,17 @@ const getProjects = async () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('favorites_api_serial', getLogger);
|
db = await dbInit('favorites_api_serial', getLogger);
|
||||||
app = await setupAppWithAuth(db.stores, {
|
app = await setupAppWithAuth(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
accessService = app.services.accessService;
|
accessService = app.services.accessService;
|
||||||
|
|
||||||
|
@ -10,13 +10,17 @@ let apiTokenStore: ApiTokenStore;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('projects_api_serial', getLogger);
|
db = await dbInit('projects_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
apiTokenStore = db.stores.apiTokenStore;
|
apiTokenStore = db.stores.apiTokenStore;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -11,13 +11,17 @@ let db: ITestDb;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('project_environments_api_serial', getLogger);
|
db = await dbInit('project_environments_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
@ -11,13 +11,17 @@ let db: ITestDb;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('project_api_tokens_serial', getLogger);
|
db = await dbInit('project_api_tokens_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
@ -8,13 +8,17 @@ let user;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('project_health_api_serial', getLogger);
|
db = await dbInit('project_health_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
user = await db.stores.userStore.insert({
|
user = await db.stores.userStore.insert({
|
||||||
name: 'Some Name',
|
name: 'Some Name',
|
||||||
email: 'test@getunleash.io',
|
email: 'test@getunleash.io',
|
||||||
|
@ -10,13 +10,17 @@ let projectStore: ProjectStore;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('projects_api_serial', getLogger);
|
db = await dbInit('projects_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
projectStore = db.stores.projectStore;
|
projectStore = db.stores.projectStore;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ let defaultToken;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('metrics_two_api_client', getLogger);
|
db = await dbInit('metrics_two_api_client', getLogger);
|
||||||
app = await setupAppWithAuth(db.stores, {});
|
app = await setupAppWithAuth(db.stores, {}, db.rawDatabase);
|
||||||
defaultToken = await app.services.apiTokenService.createApiToken({
|
defaultToken = await app.services.apiTokenService.createApiToken({
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
project: 'default',
|
project: 'default',
|
||||||
|
@ -21,9 +21,13 @@ let db: ITestDb;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('proxy', getLogger);
|
db = await dbInit('proxy', getLogger);
|
||||||
app = await setupAppWithAuth(db.stores, {
|
app = await setupAppWithAuth(
|
||||||
|
db.stores,
|
||||||
|
{
|
||||||
frontendApiOrigins: ['https://example.com'],
|
frontendApiOrigins: ['https://example.com'],
|
||||||
});
|
},
|
||||||
|
db.rawDatabase,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -263,8 +263,9 @@ export async function setupAppWithCustomAuth(
|
|||||||
preHook: Function,
|
preHook: Function,
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||||
customOptions?: any,
|
customOptions?: any,
|
||||||
|
db?: Db,
|
||||||
): Promise<IUnleashTest> {
|
): Promise<IUnleashTest> {
|
||||||
return createApp(stores, IAuthType.CUSTOM, preHook, customOptions);
|
return createApp(stores, IAuthType.CUSTOM, preHook, customOptions, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupAppWithBaseUrl(
|
export async function setupAppWithBaseUrl(
|
||||||
|
@ -6,8 +6,8 @@ import { ApiTokenType, IApiToken } from '../../../lib/types/models/api-token';
|
|||||||
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
||||||
import { addDays, subDays } from 'date-fns';
|
import { addDays, subDays } from 'date-fns';
|
||||||
import ProjectService from '../../../lib/services/project-service';
|
import ProjectService from '../../../lib/services/project-service';
|
||||||
import { EventService } from '../../../lib/services';
|
|
||||||
import { createProjectService } from '../../../lib/features';
|
import { createProjectService } from '../../../lib/features';
|
||||||
|
import { EventService } from '../../../lib/services';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
let stores;
|
let stores;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createTestConfig } from '../../config/test-config';
|
import { createTestConfig } from '../../config/test-config';
|
||||||
import dbInit from '../helpers/database-init';
|
import dbInit from '../helpers/database-init';
|
||||||
import { IUnleashStores } from '../../../lib/types/stores';
|
import { IUnleashStores } from '../../../lib/types/stores';
|
||||||
import { LastSeenService } from '../../../lib/services/client-metrics/last-seen-service';
|
import { LastSeenService } from '../../../lib/services/client-metrics/last-seen/last-seen-service';
|
||||||
import { IClientMetricsEnv } from '../../../lib/types/stores/client-metrics-store-v2';
|
import { IClientMetricsEnv } from '../../../lib/types/stores/client-metrics-store-v2';
|
||||||
|
|
||||||
let stores: IUnleashStores;
|
let stores: IUnleashStores;
|
||||||
@ -21,7 +21,13 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Should update last seen for known toggles', async () => {
|
test('Should update last seen for known toggles', async () => {
|
||||||
const service = new LastSeenService(stores, config);
|
const service = new LastSeenService(
|
||||||
|
{
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
const time = Date.now() - 100;
|
const time = Date.now() - 100;
|
||||||
await stores.featureToggleStore.create('default', { name: 'ta1' });
|
await stores.featureToggleStore.create('default', { name: 'ta1' });
|
||||||
|
|
||||||
@ -56,7 +62,14 @@ test('Should update last seen for known toggles', async () => {
|
|||||||
|
|
||||||
test('Should not update last seen toggles with 0 metrics', async () => {
|
test('Should not update last seen toggles with 0 metrics', async () => {
|
||||||
// jest.useFakeTimers();
|
// jest.useFakeTimers();
|
||||||
const service = new LastSeenService(stores, config, 30);
|
const service = new LastSeenService(
|
||||||
|
{
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
30,
|
||||||
|
);
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
await stores.featureToggleStore.create('default', { name: 'tb1' });
|
await stores.featureToggleStore.create('default', { name: 'tb1' });
|
||||||
await stores.featureToggleStore.create('default', { name: 'tb2' });
|
await stores.featureToggleStore.create('default', { name: 'tb2' });
|
||||||
@ -96,7 +109,14 @@ test('Should not update last seen toggles with 0 metrics', async () => {
|
|||||||
|
|
||||||
test('Should not update anything for 0 toggles', async () => {
|
test('Should not update anything for 0 toggles', async () => {
|
||||||
// jest.useFakeTimers();
|
// jest.useFakeTimers();
|
||||||
const service = new LastSeenService(stores, config, 30);
|
const service = new LastSeenService(
|
||||||
|
{
|
||||||
|
lastSeenStore: stores.lastSeenStore,
|
||||||
|
featureToggleStore: stores.featureToggleStore,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
30,
|
||||||
|
);
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
await stores.featureToggleStore.create('default', { name: 'tb1' });
|
await stores.featureToggleStore.create('default', { name: 'tb1' });
|
||||||
await stores.featureToggleStore.create('default', { name: 'tb2' });
|
await stores.featureToggleStore.create('default', { name: 'tb2' });
|
||||||
|
@ -4,11 +4,11 @@ import ProjectHealthService from '../../../lib/services/project-health-service';
|
|||||||
import { createTestConfig } from '../../config/test-config';
|
import { createTestConfig } from '../../config/test-config';
|
||||||
import { IUnleashStores } from '../../../lib/types';
|
import { IUnleashStores } from '../../../lib/types';
|
||||||
import { IUser } from '../../../lib/server-impl';
|
import { IUser } from '../../../lib/server-impl';
|
||||||
import { EventService } from '../../../lib/services';
|
|
||||||
import {
|
import {
|
||||||
createFeatureToggleService,
|
createFeatureToggleService,
|
||||||
createProjectService,
|
createProjectService,
|
||||||
} from '../../../lib/features';
|
} from '../../../lib/features';
|
||||||
|
import { EventService } from '../../../lib/services';
|
||||||
|
|
||||||
let stores: IUnleashStores;
|
let stores: IUnleashStores;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
IFeatureEnvironment,
|
IFeatureEnvironment,
|
||||||
IVariant,
|
IVariant,
|
||||||
} from 'lib/types/model';
|
} from 'lib/types/model';
|
||||||
import { LastSeenInput } from '../../lib/services/client-metrics/last-seen-service';
|
import { LastSeenInput } from '../../lib/services/client-metrics/last-seen/last-seen-service';
|
||||||
import { EnvironmentFeatureNames } from '../../lib/db/feature-toggle-store';
|
import { EnvironmentFeatureNames } from '../../lib/db/feature-toggle-store';
|
||||||
|
|
||||||
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||||
|
1
src/test/fixtures/store.ts
vendored
1
src/test/fixtures/store.ts
vendored
@ -85,6 +85,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
importTogglesStore: {} as IImportTogglesStore,
|
importTogglesStore: {} as IImportTogglesStore,
|
||||||
privateProjectStore: {} as IPrivateProjectStore,
|
privateProjectStore: {} as IPrivateProjectStore,
|
||||||
dependentFeaturesStore: new FakeDependentFeaturesStore(),
|
dependentFeaturesStore: new FakeDependentFeaturesStore(),
|
||||||
|
lastSeenStore: { setLastSeen: async () => {} },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user