Skip to content

Commit

Permalink
Feat: last seen in feature environment (#4391)
Browse files Browse the repository at this point in the history
- 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>
  • Loading branch information
andreas-unleash committed Aug 4, 2023
1 parent 76dc890 commit d21ccb7
Show file tree
Hide file tree
Showing 14 changed files with 229 additions and 29 deletions.
3 changes: 3 additions & 0 deletions src/lib/db/feature-environment-store.ts
Expand Up @@ -88,6 +88,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
featureName,
environment,
variants: md.variants,
lastSeenAt: md.last_seen_at,
};
}
throw new NotFoundError(
Expand Down Expand Up @@ -123,6 +124,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
featureName: r.feature_name,
environment: r.environment,
variants: r.variants,
lastSeenAt: r.last_seen_at,
}));
}

Expand Down Expand Up @@ -196,6 +198,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
environment: r.environment,
variants: r.variants || [],
enabled: r.enabled,
lastSeenAt: r.last_seen_at,
}));
}
return [];
Expand Down
5 changes: 4 additions & 1 deletion src/lib/db/feature-strategy-store.ts
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions src/lib/db/feature-toggle-client-store.ts
Expand Up @@ -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',
Expand Down Expand Up @@ -109,6 +110,7 @@ export default class FeatureToggleClientStore
'enabled',
'environment',
'variants',
'last_seen_at',
)
.where({ environment })
.as('fe'),
Expand Down Expand Up @@ -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) {
Expand Down
61 changes: 50 additions & 11 deletions src/lib/db/feature-toggle-store.ts
Expand Up @@ -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',
Expand Down Expand Up @@ -165,24 +168,60 @@ 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 {
await this.db(TABLE)
.update({ last_seen_at: now })
.whereIn(
'name',
this.db(TABLE)
.select('name')
.whereIn('name', toggleNames)
.forUpdate()
.skipLocked(),
);
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(
'name',
this.db(TABLE)
.select('name')
.whereIn('name', toggleNames)
.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,
Expand Down
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/openapi/spec/feature-environment-schema.ts
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/openapi/spec/feature-schema.ts
Expand Up @@ -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',
Expand Down
12 changes: 10 additions & 2 deletions src/lib/services/client-metrics/last-seen-service.ts
Expand Up @@ -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;

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

Expand Down
2 changes: 2 additions & 0 deletions src/lib/types/model.ts
Expand Up @@ -114,6 +114,7 @@ export interface IFeatureEnvironment {
environment: string;
featureName: string;
enabled: boolean;
lastSeenAt?: Date;
variants?: IVariant[];
}

Expand Down Expand Up @@ -170,6 +171,7 @@ export interface IEnvironmentBase {
enabled: boolean;
type: string;
sortOrder: number;
lastSeenAt: Date;
}

export interface IEnvironmentOverview extends IEnvironmentBase {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/types/stores/feature-toggle-store.ts
@@ -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;
Expand All @@ -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>;
Expand Down
@@ -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,
);
};
27 changes: 22 additions & 5 deletions src/test/e2e/api/client/metricsV2.e2e.test.ts
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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();
});

0 comments on commit d21ccb7

Please sign in to comment.