1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

Fix/last seen at by environment (#4939)

Initial architecture for last seen at by environment.
This commit is contained in:
Fredrik Strand Oseberg 2023-10-09 10:54:00 +02:00 committed by GitHub
parent 34fc17146e
commit d896dbd0c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 649 additions and 144 deletions

View File

@ -110,6 +110,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,
"variantTypeNumber": false,
},
},
@ -150,6 +151,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,
"variantTypeNumber": false,
},
"externalResolver": {

View File

@ -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[] };

View File

@ -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,
@ -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),
};
};

View File

@ -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,
@ -99,6 +101,7 @@ export const createProjectService = (
);
const privateProjectChecker = createPrivateProjectChecker(db, config);
const lastSeenReadModel = new LastSeenAtReadModel(db);
return new ProjectService(
{
@ -118,6 +121,7 @@ export const createProjectService = (
favoriteService,
eventService,
privateProjectChecker,
lastSeenReadModel,
);
};
@ -160,6 +164,7 @@ export const createFakeProjectService = (
);
const privateProjectChecker = createFakePrivateProjectChecker();
const fakeLastSeenReadModel = new FakeLastSeenReadModel();
return new ProjectService(
{
@ -179,5 +184,6 @@ export const createFakeProjectService = (
favoriteService,
eventService,
privateProjectChecker,
fakeLastSeenReadModel,
);
};

View File

@ -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,

View File

@ -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';
let db;
async function getSetup(opts?: IUnleashOptions) {
const stores = createStores();
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();
},
};
}
@ -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();
});

View File

@ -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',
},
]);
});

View File

@ -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);
};

View File

@ -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({});
}
}

View File

@ -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();
}
}

View File

@ -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;
});
}
}

View File

@ -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;
}
}

View File

@ -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;
@ -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(),
@ -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);
}
}
return count;
}

View 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;

View File

@ -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),
);
});

View File

@ -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);
});

View File

@ -0,0 +1,5 @@
import { IFeatureLastSeenResults } from '../last-seen-read-model';
export interface ILastSeenReadModel {
getForFeature(features: string[]): Promise<IFeatureLastSeenResults>;
}

View File

@ -0,0 +1,5 @@
import { LastSeenInput } from '../last-seen-service';
export interface ILastSeenStore {
setLastSeen(data: LastSeenInput[]): Promise<void>;
}

View File

@ -4,7 +4,7 @@ import getLogger from '../../../test/fixtures/no-logger';
import createStores from '../../../test/fixtures/store';
import EventEmitter from 'events';
import { LastSeenService } from './last-seen-service';
import { LastSeenService } from './last-seen/last-seen-service';
import { IUnleashConfig } from 'lib/types';
function initClientMetrics(flagEnabled = true) {
@ -23,7 +23,13 @@ function initClientMetrics(flagEnabled = true) {
},
} 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();
const service = new ClientMetricsServiceV2(stores, config, lastSeenService);

View File

@ -17,7 +17,7 @@ import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
import User from '../../types/user';
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 { ClientMetricsSchema } from 'lib/openapi';
import { nameSchema } from '../../schema/feature-schema';

View File

@ -35,7 +35,7 @@ import { ProxyService } from './proxy-service';
import EdgeService from './edge-service';
import PatService from './pat-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 { FavoritesService } from './favorites-service';
import MaintenanceService from './maintenance-service';
@ -59,7 +59,11 @@ import {
createFakeChangeRequestAccessService,
} from '../features/change-request-access-service/createChangeRequestAccessReadModel';
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 { createGroupService } from '../features/group/createGroupService';
import {
@ -77,6 +81,10 @@ import {
} from '../features/dependent-features/createDependentFeaturesService';
import { DependentFeaturesReadModel } from '../features/dependent-features/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
export const scheduleServices = async (
@ -171,7 +179,9 @@ export const createServices = (
const groupService = new GroupService(stores, config, eventService);
const accessService = new AccessService(stores, config, groupService);
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(
stores,
config,
@ -260,16 +270,10 @@ export const createServices = (
eventService,
);
const favoritesService = new FavoritesService(stores, config, eventService);
const projectService = new ProjectService(
stores,
config,
accessService,
featureToggleServiceV2,
groupService,
favoritesService,
eventService,
privateProjectChecker,
);
const projectService = db
? createProjectService(db, config)
: createFakeProjectService(config);
const projectHealthService = new ProjectHealthService(
stores,
config,

View File

@ -65,6 +65,8 @@ import { ProjectDoraMetricsSchema } from 'lib/openapi';
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
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';
@ -118,6 +120,8 @@ export default class ProjectService {
private projectStatsStore: IProjectStatsStore;
private lastSeenReadModel: ILastSeenReadModel;
private flagResolver: IFlagResolver;
private isEnterprise: boolean;
@ -150,6 +154,7 @@ export default class ProjectService {
favoriteService: FavoritesService,
eventService: EventService,
privateProjectChecker: IPrivateProjectChecker,
lastSeenReadModel: ILastSeenReadModel,
) {
this.projectStore = projectStore;
this.environmentStore = environmentStore;
@ -165,6 +170,7 @@ export default class ProjectService {
this.groupService = groupService;
this.eventService = eventService;
this.projectStatsStore = projectStatsStore;
this.lastSeenReadModel = lastSeenReadModel;
this.logger = config.getLogger('services/project-service.js');
this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise;
@ -1071,6 +1077,22 @@ export default class ProjectService {
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 {
stats: projectStats,
name: project.name,
@ -1084,7 +1106,7 @@ export default class ProjectService {
updatedAt: project.updatedAt,
createdAt: project.createdAt,
environments,
features,
features: decoratedFeatures,
members,
version: 1,
};

View File

@ -32,7 +32,8 @@ export type IFlagKey =
| 'dependentFeatures'
| 'datadogJsonTemplate'
| 'disableMetrics'
| 'transactionalDecorator';
| 'transactionalDecorator'
| 'useLastSeenRefactor';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -152,6 +153,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_TRANSACTIONAL_DECORATOR,
false,
),
useLastSeenRefactor: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_USE_LAST_SEEN_REFACTOR,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -32,7 +32,7 @@ import { ProxyService } from '../services/proxy-service';
import EdgeService from '../services/edge-service';
import PatService from '../services/pat-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 { FavoritesService } from '../services/favorites-service';
import MaintenanceService from '../services/maintenance-service';

View File

@ -35,6 +35,7 @@ import { IProjectStatsStore } from './stores/project-stats-store-type';
import { IImportTogglesStore } from '../features/export-import-toggles/import-toggles-store-type';
import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType';
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 {
accessStore: IAccessStore;
@ -74,6 +75,7 @@ export interface IUnleashStores {
importTogglesStore: IImportTogglesStore;
privateProjectStore: IPrivateProjectStore;
dependentFeaturesStore: IDependentFeaturesStore;
lastSeenStore: ILastSeenStore;
}
export {
@ -113,4 +115,5 @@ export {
IImportTogglesStore,
IPrivateProjectStore,
IDependentFeaturesStore,
ILastSeenStore,
};

View File

@ -1,6 +1,6 @@
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
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 {
archived: boolean;

View 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(),
);
};

View File

@ -46,6 +46,7 @@ process.nextTick(async () => {
datadogJsonTemplate: true,
dependentFeatures: true,
transactionalDecorator: true,
useLastSeenRefactor: true,
},
},
authentication: {

View File

@ -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
.post('/api/admin/projects/default/api-tokens')

View File

@ -10,13 +10,17 @@ let app;
beforeAll(async () => {
db = await dbInit('token_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
});
afterAll(async () => {

View File

@ -81,13 +81,17 @@ const getProjects = async () => {
beforeAll(async () => {
db = await dbInit('favorites_api_serial', getLogger);
app = await setupAppWithAuth(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
stores = db.stores;
accessService = app.services.accessService;

View File

@ -10,13 +10,17 @@ let apiTokenStore: ApiTokenStore;
beforeAll(async () => {
db = await dbInit('projects_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
apiTokenStore = db.stores.apiTokenStore;
});

View File

@ -11,13 +11,17 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('project_environments_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
});
afterEach(async () => {

View File

@ -11,13 +11,17 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('project_api_tokens_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
});
afterEach(async () => {

View File

@ -8,13 +8,17 @@ let user;
beforeAll(async () => {
db = await dbInit('project_health_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
user = await db.stores.userStore.insert({
name: 'Some Name',
email: 'test@getunleash.io',

View File

@ -10,13 +10,17 @@ let projectStore: ProjectStore;
beforeAll(async () => {
db = await dbInit('projects_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
});
db.rawDatabase,
);
projectStore = db.stores.projectStore;
});

View File

@ -11,7 +11,7 @@ let defaultToken;
beforeAll(async () => {
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({
type: ApiTokenType.CLIENT,
project: 'default',

View File

@ -21,9 +21,13 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('proxy', getLogger);
app = await setupAppWithAuth(db.stores, {
frontendApiOrigins: ['https://example.com'],
});
app = await setupAppWithAuth(
db.stores,
{
frontendApiOrigins: ['https://example.com'],
},
db.rawDatabase,
);
});
afterEach(() => {

View File

@ -263,8 +263,9 @@ export async function setupAppWithCustomAuth(
preHook: Function,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
customOptions?: any,
db?: Db,
): Promise<IUnleashTest> {
return createApp(stores, IAuthType.CUSTOM, preHook, customOptions);
return createApp(stores, IAuthType.CUSTOM, preHook, customOptions, db);
}
export async function setupAppWithBaseUrl(

View File

@ -6,8 +6,8 @@ import { ApiTokenType, IApiToken } from '../../../lib/types/models/api-token';
import { DEFAULT_ENV } from '../../../lib/util/constants';
import { addDays, subDays } from 'date-fns';
import ProjectService from '../../../lib/services/project-service';
import { EventService } from '../../../lib/services';
import { createProjectService } from '../../../lib/features';
import { EventService } from '../../../lib/services';
let db;
let stores;

View File

@ -1,7 +1,7 @@
import { createTestConfig } from '../../config/test-config';
import dbInit from '../helpers/database-init';
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';
let stores: IUnleashStores;
@ -21,7 +21,13 @@ afterAll(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;
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 () => {
// 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();
await stores.featureToggleStore.create('default', { name: 'tb1' });
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 () => {
// 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();
await stores.featureToggleStore.create('default', { name: 'tb1' });
await stores.featureToggleStore.create('default', { name: 'tb2' });

View File

@ -4,11 +4,11 @@ import ProjectHealthService from '../../../lib/services/project-health-service';
import { createTestConfig } from '../../config/test-config';
import { IUnleashStores } from '../../../lib/types';
import { IUser } from '../../../lib/server-impl';
import { EventService } from '../../../lib/services';
import {
createFeatureToggleService,
createProjectService,
} from '../../../lib/features';
import { EventService } from '../../../lib/services';
let stores: IUnleashStores;
let db: ITestDb;

View File

@ -9,7 +9,7 @@ import {
IFeatureEnvironment,
IVariant,
} 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';
export default class FakeFeatureToggleStore implements IFeatureToggleStore {

View File

@ -85,6 +85,7 @@ const createStores: () => IUnleashStores = () => {
importTogglesStore: {} as IImportTogglesStore,
privateProjectStore: {} as IPrivateProjectStore,
dependentFeaturesStore: new FakeDependentFeaturesStore(),
lastSeenStore: { setLastSeen: async () => {} },
};
};