1
0
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:
andreas-unleash 2023-08-04 09:59:54 +03:00 committed by GitHub
parent 76dc89069c
commit d21ccb7f1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 229 additions and 29 deletions

View File

@ -88,6 +88,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
featureName, featureName,
environment, environment,
variants: md.variants, variants: md.variants,
lastSeenAt: md.last_seen_at,
}; };
} }
throw new NotFoundError( throw new NotFoundError(
@ -123,6 +124,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
featureName: r.feature_name, featureName: r.feature_name,
environment: r.environment, environment: r.environment,
variants: r.variants, variants: r.variants,
lastSeenAt: r.last_seen_at,
})); }));
} }
@ -196,6 +198,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
environment: r.environment, environment: r.environment,
variants: r.variants || [], variants: r.variants || [],
enabled: r.enabled, enabled: r.enabled,
lastSeenAt: r.last_seen_at,
})); }));
} }
return []; return [];

View File

@ -348,13 +348,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
acc.description = r.description; acc.description = r.description;
acc.project = r.project; acc.project = r.project;
acc.stale = r.stale; acc.stale = r.stale;
acc.lastSeenAt = r.last_seen_at;
acc.createdAt = r.created_at; acc.createdAt = r.created_at;
acc.lastSeenAt = r.last_seen_at;
acc.type = r.type; acc.type = r.type;
if (!acc.environments[r.environment]) { if (!acc.environments[r.environment]) {
acc.environments[r.environment] = { acc.environments[r.environment] = {
name: r.environment, name: r.environment,
lastSeenAt: r.env_last_seen_at,
}; };
} }
const env = acc.environments[r.environment]; const env = acc.environments[r.environment];
@ -443,6 +444,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
type: r.environment_type, type: r.environment_type,
sortOrder: r.environment_sort_order, sortOrder: r.environment_sort_order,
variantCount: r.variants?.length || 0, 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.enabled as enabled',
'feature_environments.environment as environment', 'feature_environments.environment as environment',
'feature_environments.variants as variants', 'feature_environments.variants as variants',
'feature_environments.last_seen_at as env_last_seen_at',
'environments.type as environment_type', 'environments.type as environment_type',
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value', 'ft.tag_value as tag_value',

View File

@ -75,9 +75,10 @@ export default class FeatureToggleClientStore
'features.project as project', 'features.project as project',
'features.stale as stale', 'features.stale as stale',
'features.impression_data as impression_data', '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.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.enabled as enabled',
'fe.environment as environment', 'fe.environment as environment',
'fs.id as strategy_id', 'fs.id as strategy_id',
@ -109,6 +110,7 @@ export default class FeatureToggleClientStore
'enabled', 'enabled',
'environment', 'environment',
'variants', 'variants',
'last_seen_at',
) )
.where({ environment }) .where({ environment })
.as('fe'), .as('fe'),
@ -200,6 +202,7 @@ export default class FeatureToggleClientStore
feature.project = r.project; feature.project = r.project;
feature.stale = r.stale; feature.stale = r.stale;
feature.type = r.type; feature.type = r.type;
feature.lastSeenAt = r.last_seen_at;
feature.variants = r.variants || []; feature.variants = r.variants || [];
feature.project = r.project; feature.project = r.project;
if (isAdmin) { if (isAdmin) {

View File

@ -7,6 +7,9 @@ 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';
export type EnvironmentFeatureNames = { [key: string]: string[] };
const FEATURE_COLUMNS = [ const FEATURE_COLUMNS = [
'name', 'name',
@ -165,9 +168,25 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return present; return present;
} }
async setLastSeen(toggleNames: string[]): Promise<void> { async setLastSeen(data: LastSeenInput[]): Promise<void> {
const now = new Date(); const now = new Date();
const environmentArrays = this.mapMetricDataToEnvBuckets(data);
try { 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) await this.db(TABLE)
.update({ last_seen_at: now }) .update({ last_seen_at: now })
.whereIn( .whereIn(
@ -178,11 +197,31 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.forUpdate() .forUpdate()
.skipLocked(), .skipLocked(),
); );
}
} catch (err) { } catch (err) {
this.logger.error('Could not update lastSeen, error: ', 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 = ( static filterByArchived: Knex.QueryCallbackWithArgs = (
queryBuilder: Knex.QueryBuilder, queryBuilder: Knex.QueryBuilder,
archived: boolean, archived: boolean,

View File

@ -643,6 +643,7 @@ export default class ExportImportService {
featureEnvironments: featureEnvironments.map((item) => ({ featureEnvironments: featureEnvironments.map((item) => ({
...item, ...item,
name: item.featureName, name: item.featureName,
lastSeenAt: item.lastSeenAt?.toISOString(),
})), })),
contextFields: filteredContextFields.map((item) => { contextFields: filteredContextFields.map((item) => {
const { createdAt, ...rest } = item; const { createdAt, ...rest } = item;

View File

@ -63,6 +63,14 @@ export const featureEnvironmentSchema = {
}, },
description: 'A list of variants for the feature environment', 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: { components: {
schemas: { schemas: {

View File

@ -84,9 +84,10 @@ export const featureSchema = {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',
nullable: true, nullable: true,
deprecated: true,
example: '2023-01-28T16:21:39.975Z', example: '2023-01-28T16:21:39.975Z',
description: 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: { environments: {
type: 'array', type: 'array',

View File

@ -5,10 +5,15 @@ import { IUnleashStores } from '../../types';
import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2'; import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
export type LastSeenInput = {
featureName: string;
environment: string;
};
export class LastSeenService { export class LastSeenService {
private timers: NodeJS.Timeout[] = []; private timers: NodeJS.Timeout[] = [];
private lastSeenToggles: Set<string> = new Set(); private lastSeenToggles: Set<LastSeenInput> = new Set();
private logger: Logger; private logger: Logger;
@ -48,7 +53,10 @@ export class LastSeenService {
(clientMetric) => clientMetric.yes > 0 || clientMetric.no > 0, (clientMetric) => clientMetric.yes > 0 || clientMetric.no > 0,
) )
.forEach((clientMetric) => .forEach((clientMetric) =>
this.lastSeenToggles.add(clientMetric.featureName), this.lastSeenToggles.add({
featureName: clientMetric.featureName,
environment: clientMetric.environment,
}),
); );
} }

View File

@ -114,6 +114,7 @@ export interface IFeatureEnvironment {
environment: string; environment: string;
featureName: string; featureName: string;
enabled: boolean; enabled: boolean;
lastSeenAt?: Date;
variants?: IVariant[]; variants?: IVariant[];
} }
@ -170,6 +171,7 @@ export interface IEnvironmentBase {
enabled: boolean; enabled: boolean;
type: string; type: string;
sortOrder: number; sortOrder: number;
lastSeenAt: Date;
} }
export interface IEnvironmentOverview extends IEnvironmentBase { export interface IEnvironmentOverview extends IEnvironmentBase {

View File

@ -1,5 +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';
export interface IFeatureToggleQuery { export interface IFeatureToggleQuery {
archived: boolean; archived: boolean;
@ -10,7 +11,7 @@ export interface IFeatureToggleQuery {
export interface IFeatureToggleStore extends Store<FeatureToggle, string> { export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
count(query?: Partial<IFeatureToggleQuery>): Promise<number>; count(query?: Partial<IFeatureToggleQuery>): Promise<number>;
setLastSeen(toggleNames: string[]): Promise<void>; setLastSeen(data: LastSeenInput[]): Promise<void>;
getProjectId(name: string): Promise<string | undefined>; getProjectId(name: string): Promise<string | undefined>;
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>; create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>; update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;

View File

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

View File

@ -98,7 +98,7 @@ test('should pick up environment from token', async () => {
expect(metrics[0].appName).toBe('some-fancy-app'); 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(); const start = Date.now();
await app.services.featureToggleServiceV2.createFeatureToggle( await app.services.featureToggleServiceV2.createFeatureToggle(
'default', 'default',
@ -110,6 +110,7 @@ test('should set lastSeen for toggles with metrics', async () => {
{ name: 't2' }, { name: 't2' },
'tester', 'tester',
); );
const token = await app.services.apiTokenService.createApiToken({ const token = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
project: 'default', project: 'default',
@ -142,8 +143,24 @@ test('should set lastSeen for toggles with metrics', async () => {
await app.services.clientMetricsServiceV2.bulkAdd(); await app.services.clientMetricsServiceV2.bulkAdd();
await app.services.lastSeenService.store(); await app.services.lastSeenService.store();
const t1 = await db.stores.featureToggleStore.get('t1'); const t1 = await app.services.featureToggleServiceV2.getFeature({
const t2 = await db.stores.featureToggleStore.get('t2'); featureName: 't1',
expect(t1.lastSeenAt.getTime()).toBeGreaterThanOrEqual(start); archived: false,
expect(t2.lastSeenAt).toBeDefined(); 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();
}); });

View File

@ -62,6 +62,7 @@ test('Can connect environment to project', async () => {
sortOrder: 9999, sortOrder: 9999,
type: 'production', type: 'production',
variantCount: 0, variantCount: 0,
lastSeenAt: null,
}, },
]); ]);
}); });
@ -89,6 +90,7 @@ test('Can remove environment from project', async () => {
sortOrder: 9999, sortOrder: 9999,
type: 'production', type: 'production',
variantCount: 0, variantCount: 0,
lastSeenAt: null,
}, },
]); ]);
}); });

View File

@ -9,6 +9,8 @@ import {
IFeatureEnvironment, IFeatureEnvironment,
IVariant, IVariant,
} from 'lib/types/model'; } 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 { export default class FakeFeatureToggleStore implements IFeatureToggleStore {
features: FeatureToggle[] = []; features: FeatureToggle[] = [];
@ -161,7 +163,25 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new NotFoundError('Could not find feature to update'); 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) => { toggleNames.forEach((t) => {
const toUpdate = this.features.find((f) => f.name === t); const toUpdate = this.features.find((f) => f.name === t);
if (toUpdate) { if (toUpdate) {
@ -169,6 +189,8 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
} }
}); });
} }
}
}
async getVariants(featureName: string): Promise<IVariant[]> { async getVariants(featureName: string): Promise<IVariant[]> {
const feature = await this.get(featureName); const feature = await this.get(featureName);