mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-04 00:18:40 +01:00
Feat: last seen in feature environment (#4391)
- Adds last_seen_at column in feature_environments and lastSeenAt property to the FeatureEnvironment models Closes [1-1181](https://linear.app/unleash/issue/1-1181/implement-storing-last-seen-per-environment-be) --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
76dc89069c
commit
d21ccb7f1c
@ -88,6 +88,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
|
||||
featureName,
|
||||
environment,
|
||||
variants: md.variants,
|
||||
lastSeenAt: md.last_seen_at,
|
||||
};
|
||||
}
|
||||
throw new NotFoundError(
|
||||
@ -123,6 +124,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
|
||||
featureName: r.feature_name,
|
||||
environment: r.environment,
|
||||
variants: r.variants,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -196,6 +198,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
|
||||
environment: r.environment,
|
||||
variants: r.variants || [],
|
||||
enabled: r.enabled,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
|
@ -348,13 +348,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
acc.description = r.description;
|
||||
acc.project = r.project;
|
||||
acc.stale = r.stale;
|
||||
acc.lastSeenAt = r.last_seen_at;
|
||||
|
||||
acc.createdAt = r.created_at;
|
||||
acc.lastSeenAt = r.last_seen_at;
|
||||
acc.type = r.type;
|
||||
if (!acc.environments[r.environment]) {
|
||||
acc.environments[r.environment] = {
|
||||
name: r.environment,
|
||||
lastSeenAt: r.env_last_seen_at,
|
||||
};
|
||||
}
|
||||
const env = acc.environments[r.environment];
|
||||
@ -443,6 +444,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
type: r.environment_type,
|
||||
sortOrder: r.environment_sort_order,
|
||||
variantCount: r.variants?.length || 0,
|
||||
lastSeenAt: r.env_last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
@ -523,6 +525,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
'feature_environments.enabled as enabled',
|
||||
'feature_environments.environment as environment',
|
||||
'feature_environments.variants as variants',
|
||||
'feature_environments.last_seen_at as env_last_seen_at',
|
||||
'environments.type as environment_type',
|
||||
'environments.sort_order as environment_sort_order',
|
||||
'ft.tag_value as tag_value',
|
||||
|
@ -75,9 +75,10 @@ export default class FeatureToggleClientStore
|
||||
'features.project as project',
|
||||
'features.stale as stale',
|
||||
'features.impression_data as impression_data',
|
||||
'fe.variants as variants',
|
||||
'features.created_at as created_at',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
'features.created_at as created_at',
|
||||
'fe.variants as variants',
|
||||
'fe.last_seen_at as env_last_seen_at',
|
||||
'fe.enabled as enabled',
|
||||
'fe.environment as environment',
|
||||
'fs.id as strategy_id',
|
||||
@ -109,6 +110,7 @@ export default class FeatureToggleClientStore
|
||||
'enabled',
|
||||
'environment',
|
||||
'variants',
|
||||
'last_seen_at',
|
||||
)
|
||||
.where({ environment })
|
||||
.as('fe'),
|
||||
@ -200,6 +202,7 @@ export default class FeatureToggleClientStore
|
||||
feature.project = r.project;
|
||||
feature.stale = r.stale;
|
||||
feature.type = r.type;
|
||||
feature.lastSeenAt = r.last_seen_at;
|
||||
feature.variants = r.variants || [];
|
||||
feature.project = r.project;
|
||||
if (isAdmin) {
|
||||
|
@ -7,6 +7,9 @@ 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';
|
||||
|
||||
export type EnvironmentFeatureNames = { [key: string]: string[] };
|
||||
|
||||
const FEATURE_COLUMNS = [
|
||||
'name',
|
||||
@ -165,9 +168,25 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
return present;
|
||||
}
|
||||
|
||||
async setLastSeen(toggleNames: string[]): Promise<void> {
|
||||
async setLastSeen(data: LastSeenInput[]): Promise<void> {
|
||||
const now = new Date();
|
||||
const environmentArrays = this.mapMetricDataToEnvBuckets(data);
|
||||
try {
|
||||
for (const env of Object.keys(environmentArrays)) {
|
||||
const toggleNames = environmentArrays[env];
|
||||
await this.db(FEATURE_ENVIRONMENTS_TABLE)
|
||||
.update({ last_seen_at: now })
|
||||
.where('environment', env)
|
||||
.whereIn(
|
||||
'feature_name',
|
||||
this.db(FEATURE_ENVIRONMENTS_TABLE)
|
||||
.select('feature_name')
|
||||
.whereIn('feature_name', toggleNames)
|
||||
.forUpdate()
|
||||
.skipLocked(),
|
||||
);
|
||||
|
||||
// Updating the toggle's last_seen_at also for backwards compatibility
|
||||
await this.db(TABLE)
|
||||
.update({ last_seen_at: now })
|
||||
.whereIn(
|
||||
@ -178,11 +197,31 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
.forUpdate()
|
||||
.skipLocked(),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Could not update lastSeen, error: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
private mapMetricDataToEnvBuckets(
|
||||
data: LastSeenInput[],
|
||||
): EnvironmentFeatureNames {
|
||||
return data.reduce(
|
||||
(acc: EnvironmentFeatureNames, feature: LastSeenInput) => {
|
||||
const { environment, featureName } = feature;
|
||||
|
||||
if (!acc[environment]) {
|
||||
acc[environment] = [];
|
||||
}
|
||||
|
||||
acc[environment].push(featureName);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
static filterByArchived: Knex.QueryCallbackWithArgs = (
|
||||
queryBuilder: Knex.QueryBuilder,
|
||||
archived: boolean,
|
||||
|
@ -643,6 +643,7 @@ export default class ExportImportService {
|
||||
featureEnvironments: featureEnvironments.map((item) => ({
|
||||
...item,
|
||||
name: item.featureName,
|
||||
lastSeenAt: item.lastSeenAt?.toISOString(),
|
||||
})),
|
||||
contextFields: filteredContextFields.map((item) => {
|
||||
const { createdAt, ...rest } = item;
|
||||
|
@ -63,6 +63,14 @@ export const featureEnvironmentSchema = {
|
||||
},
|
||||
description: 'A list of variants for the feature environment',
|
||||
},
|
||||
lastSeenAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2023-01-28T16:21:39.975Z',
|
||||
description:
|
||||
'The date when metrics where last collected for the feature environment',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
|
@ -84,9 +84,10 @@ export const featureSchema = {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
deprecated: true,
|
||||
example: '2023-01-28T16:21:39.975Z',
|
||||
description:
|
||||
'The date when metrics where last collected for the feature',
|
||||
'The date when metrics where last collected for the feature. This field is deprecated, use the one in featureEnvironmentSchema',
|
||||
},
|
||||
environments: {
|
||||
type: 'array',
|
||||
|
@ -5,10 +5,15 @@ import { IUnleashStores } from '../../types';
|
||||
import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2';
|
||||
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
|
||||
|
||||
export type LastSeenInput = {
|
||||
featureName: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export class LastSeenService {
|
||||
private timers: NodeJS.Timeout[] = [];
|
||||
|
||||
private lastSeenToggles: Set<string> = new Set();
|
||||
private lastSeenToggles: Set<LastSeenInput> = new Set();
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
@ -48,7 +53,10 @@ export class LastSeenService {
|
||||
(clientMetric) => clientMetric.yes > 0 || clientMetric.no > 0,
|
||||
)
|
||||
.forEach((clientMetric) =>
|
||||
this.lastSeenToggles.add(clientMetric.featureName),
|
||||
this.lastSeenToggles.add({
|
||||
featureName: clientMetric.featureName,
|
||||
environment: clientMetric.environment,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -114,6 +114,7 @@ export interface IFeatureEnvironment {
|
||||
environment: string;
|
||||
featureName: string;
|
||||
enabled: boolean;
|
||||
lastSeenAt?: Date;
|
||||
variants?: IVariant[];
|
||||
}
|
||||
|
||||
@ -170,6 +171,7 @@ export interface IEnvironmentBase {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
sortOrder: number;
|
||||
lastSeenAt: Date;
|
||||
}
|
||||
|
||||
export interface IEnvironmentOverview extends IEnvironmentBase {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
||||
import { Store } from './store';
|
||||
import { LastSeenInput } from '../../services/client-metrics/last-seen-service';
|
||||
|
||||
export interface IFeatureToggleQuery {
|
||||
archived: boolean;
|
||||
@ -10,7 +11,7 @@ export interface IFeatureToggleQuery {
|
||||
|
||||
export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
count(query?: Partial<IFeatureToggleQuery>): Promise<number>;
|
||||
setLastSeen(toggleNames: string[]): Promise<void>;
|
||||
setLastSeen(data: LastSeenInput[]): Promise<void>;
|
||||
getProjectId(name: string): Promise<string | undefined>;
|
||||
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
||||
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
|
||||
|
@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE feature_environments ADD COLUMN IF NOT EXISTS last_seen_at timestamp with time zone;
|
||||
|
||||
DROP VIEW features_view;
|
||||
|
||||
CREATE VIEW features_view AS
|
||||
SELECT
|
||||
features.name as name,
|
||||
features.description as description,
|
||||
features.type as type,
|
||||
features.project as project,
|
||||
features.stale as stale,
|
||||
features.impression_data as impression_data,
|
||||
features.created_at as created_at,
|
||||
features.archived_at as archived_at,
|
||||
features.last_seen_at as last_seen_at,
|
||||
feature_environments.last_seen_at as env_last_seen_at,
|
||||
feature_environments.enabled as enabled,
|
||||
feature_environments.environment as environment,
|
||||
feature_environments.variants as variants,
|
||||
environments.name as environment_name,
|
||||
environments.type as environment_type,
|
||||
environments.sort_order as environment_sort_order,
|
||||
feature_strategies.id as strategy_id,
|
||||
feature_strategies.strategy_name as strategy_name,
|
||||
feature_strategies.parameters as parameters,
|
||||
feature_strategies.constraints as constraints,
|
||||
feature_strategies.sort_order as sort_order,
|
||||
fss.segment_id as segments,
|
||||
feature_strategies.title as strategy_title,
|
||||
feature_strategies.disabled as strategy_disabled,
|
||||
feature_strategies.variants as strategy_variants
|
||||
FROM
|
||||
features
|
||||
LEFT JOIN feature_environments ON feature_environments.feature_name = features.name
|
||||
LEFT JOIN feature_strategies ON feature_strategies.feature_name = feature_environments.feature_name
|
||||
and feature_strategies.environment = feature_environments.environment
|
||||
LEFT JOIN environments ON feature_environments.environment = environments.name
|
||||
LEFT JOIN feature_strategy_segment as fss ON fss.feature_strategy_id = feature_strategies.id;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
DROP VIEW features_view;
|
||||
|
||||
CREATE VIEW features_view AS
|
||||
SELECT
|
||||
features.name as name,
|
||||
features.description as description,
|
||||
features.type as type,
|
||||
features.project as project,
|
||||
features.stale as stale,
|
||||
features.impression_data as impression_data,
|
||||
features.created_at as created_at,
|
||||
features.archived_at as archived_at,
|
||||
feature_environments.last_seen_at as last_seen_at,
|
||||
feature_environments.enabled as enabled,
|
||||
feature_environments.environment as environment,
|
||||
feature_environments.variants as variants,
|
||||
environments.name as environment_name,
|
||||
environments.type as environment_type,
|
||||
environments.sort_order as environment_sort_order,
|
||||
feature_strategies.id as strategy_id,
|
||||
feature_strategies.strategy_name as strategy_name,
|
||||
feature_strategies.parameters as parameters,
|
||||
feature_strategies.constraints as constraints,
|
||||
feature_strategies.sort_order as sort_order,
|
||||
fss.segment_id as segments,
|
||||
feature_strategies.title as strategy_title,
|
||||
feature_strategies.disabled as strategy_disabled,
|
||||
feature_strategies.variants as strategy_variants
|
||||
FROM
|
||||
features
|
||||
LEFT JOIN feature_environments ON feature_environments.feature_name = features.name
|
||||
LEFT JOIN feature_strategies ON feature_strategies.feature_name = feature_environments.feature_name
|
||||
and feature_strategies.environment = feature_environments.environment
|
||||
LEFT JOIN environments ON feature_environments.environment = environments.name
|
||||
LEFT JOIN feature_strategy_segment as fss ON fss.feature_strategy_id = feature_strategies.id;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
@ -98,7 +98,7 @@ test('should pick up environment from token', async () => {
|
||||
expect(metrics[0].appName).toBe('some-fancy-app');
|
||||
});
|
||||
|
||||
test('should set lastSeen for toggles with metrics', async () => {
|
||||
test('should set lastSeen for toggles with metrics both for toggle and toggle env', async () => {
|
||||
const start = Date.now();
|
||||
await app.services.featureToggleServiceV2.createFeatureToggle(
|
||||
'default',
|
||||
@ -110,6 +110,7 @@ test('should set lastSeen for toggles with metrics', async () => {
|
||||
{ name: 't2' },
|
||||
'tester',
|
||||
);
|
||||
|
||||
const token = await app.services.apiTokenService.createApiToken({
|
||||
type: ApiTokenType.CLIENT,
|
||||
project: 'default',
|
||||
@ -142,8 +143,24 @@ test('should set lastSeen for toggles with metrics', async () => {
|
||||
|
||||
await app.services.clientMetricsServiceV2.bulkAdd();
|
||||
await app.services.lastSeenService.store();
|
||||
const t1 = await db.stores.featureToggleStore.get('t1');
|
||||
const t2 = await db.stores.featureToggleStore.get('t2');
|
||||
expect(t1.lastSeenAt.getTime()).toBeGreaterThanOrEqual(start);
|
||||
expect(t2.lastSeenAt).toBeDefined();
|
||||
const t1 = await app.services.featureToggleServiceV2.getFeature({
|
||||
featureName: 't1',
|
||||
archived: false,
|
||||
environmentVariants: true,
|
||||
projectId: 'default',
|
||||
});
|
||||
const t2 = await app.services.featureToggleServiceV2.getFeature({
|
||||
featureName: 't2',
|
||||
archived: false,
|
||||
environmentVariants: true,
|
||||
projectId: 'default',
|
||||
});
|
||||
|
||||
const t1Env = t1.environments.find((e) => e.name === 'default');
|
||||
const t2Env = t2.environments.find((e) => e.name === 'default');
|
||||
|
||||
expect(t1.lastSeenAt?.getTime()).toBeGreaterThanOrEqual(start);
|
||||
expect(t1Env?.lastSeenAt.getTime()).toBeGreaterThanOrEqual(start);
|
||||
expect(t2?.lastSeenAt).toBeDefined();
|
||||
expect(t2Env?.lastSeenAt).toBeDefined();
|
||||
});
|
||||
|
@ -62,6 +62,7 @@ test('Can connect environment to project', async () => {
|
||||
sortOrder: 9999,
|
||||
type: 'production',
|
||||
variantCount: 0,
|
||||
lastSeenAt: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -89,6 +90,7 @@ test('Can remove environment from project', async () => {
|
||||
sortOrder: 9999,
|
||||
type: 'production',
|
||||
variantCount: 0,
|
||||
lastSeenAt: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
24
src/test/fixtures/fake-feature-toggle-store.ts
vendored
24
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -9,6 +9,8 @@ import {
|
||||
IFeatureEnvironment,
|
||||
IVariant,
|
||||
} from 'lib/types/model';
|
||||
import { LastSeenInput } from '../../lib/services/client-metrics/last-seen-service';
|
||||
import { EnvironmentFeatureNames } from '../../lib/db/feature-toggle-store';
|
||||
|
||||
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
features: FeatureToggle[] = [];
|
||||
@ -161,7 +163,25 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
throw new NotFoundError('Could not find feature to update');
|
||||
}
|
||||
|
||||
async setLastSeen(toggleNames: string[]): Promise<void> {
|
||||
async setLastSeen(data: LastSeenInput[]): Promise<void> {
|
||||
const envArrays = data.reduce(
|
||||
(acc: EnvironmentFeatureNames, feature: LastSeenInput) => {
|
||||
const { environment, featureName } = feature;
|
||||
|
||||
if (!acc[environment]) {
|
||||
acc[environment] = [];
|
||||
}
|
||||
|
||||
acc[environment].push(featureName);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
for (const env of Object.keys(envArrays)) {
|
||||
const toggleNames = envArrays[env];
|
||||
if (toggleNames && Array.isArray(toggleNames)) {
|
||||
toggleNames.forEach((t) => {
|
||||
const toUpdate = this.features.find((f) => f.name === t);
|
||||
if (toUpdate) {
|
||||
@ -169,6 +189,8 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||
const feature = await this.get(featureName);
|
||||
|
Loading…
Reference in New Issue
Block a user