unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
278 lines • 12.7 kB
JavaScript
import { clientMetricsSchema, impactMetricsSchema } from '../shared/schema.js';
import { compareAsc, secondsToMilliseconds } from 'date-fns';
import { CLIENT_METRICS, CLIENT_METRICS_ADDED, CLIENT_REGISTER, } from '../../../events/index.js';
import ApiUser from '../../../types/api-user.js';
import { ALL } from '../../../types/models/api-token.js';
import { collapseHourlyMetrics } from './collapseHourlyMetrics.js';
import { generateDayBuckets, generateHourBuckets, } from '../../../util/time-utils.js';
import { nameSchema } from '../../../schema/feature-schema.js';
import memoizee from 'memoizee';
import { MetricsTranslator, } from '../impact/metrics-translator.js';
import { impactRegister } from '../impact/impact-register.js';
export default class ClientMetricsServiceV2 {
constructor({ clientMetricsStoreV2 }, config, lastSeenService, unknownFlagsService) {
this.unsavedMetrics = [];
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.lastSeenService = lastSeenService;
this.unknownFlagsService = unknownFlagsService;
this.config = config;
this.logger = config.getLogger('/services/client-metrics/client-metrics-service-v2.ts');
this.flagResolver = config.flagResolver;
this.cachedFeatureNames = memoizee(async () => this.clientMetricsStoreV2.getFeatureFlagNames(), {
promise: true,
maxAge: secondsToMilliseconds(10),
});
this.impactMetricsTranslator = new MetricsTranslator(impactRegister);
}
async clearMetrics(hoursAgo) {
return this.clientMetricsStoreV2.clearMetrics(hoursAgo);
}
async clearDailyMetrics(daysAgo) {
if (this.flagResolver.isEnabled('extendedUsageMetrics')) {
return this.clientMetricsStoreV2.clearDailyMetrics(daysAgo);
}
}
async aggregateDailyMetrics() {
if (this.flagResolver.isEnabled('extendedUsageMetrics')) {
const { enabledCount: hourlyEnabledCount, variantCount: hourlyVariantCount, } = await this.clientMetricsStoreV2.countPreviousDayHourlyMetricsBuckets();
const { enabledCount: dailyEnabledCount, variantCount: dailyVariantCount, } = await this.clientMetricsStoreV2.countPreviousDayMetricsBuckets();
const { payload } = this.flagResolver.getVariant('extendedUsageMetrics');
const limit = payload?.value &&
Number.isInteger(Number.parseInt(payload?.value))
? Number.parseInt(payload?.value)
: 3600000;
const totalHourlyCount = hourlyEnabledCount + hourlyVariantCount;
const totalDailyCount = dailyEnabledCount + dailyVariantCount;
const previousDayDailyCountCalculated = totalDailyCount > totalHourlyCount / 24; // heuristic
if (previousDayDailyCountCalculated) {
return;
}
if (totalHourlyCount > limit) {
this.logger.warn(`Skipping previous day metrics aggregation. Too many results. Expected max value: ${limit}, Actual value: ${totalHourlyCount}`);
return;
}
await this.clientMetricsStoreV2.aggregateDailyMetrics();
}
}
async filterExistingToggleNames(toggleNames) {
const existingFlags = await this.cachedFeatureNames();
const existingNames = toggleNames.filter((name) => existingFlags.includes(name));
let unknownToggleNames = [];
if (this.flagResolver.isEnabled('reportUnknownFlags')) {
try {
unknownToggleNames = toggleNames.filter((name) => !existingFlags.includes(name));
}
catch (e) {
this.logger.error(e);
}
}
const validatedToggleNames = await this.filterValidToggleNames(existingNames);
return { validatedToggleNames, unknownToggleNames };
}
async filterValidToggleNames(toggleNames) {
const nameValidations = toggleNames.map((toggleName) => nameSchema.validateAsync({ name: toggleName }));
const badNames = (await Promise.allSettled(nameValidations)).filter((r) => r.status === 'rejected');
if (badNames.length > 0) {
this.logger.warn(`Got a few toggles with invalid names: ${JSON.stringify(badNames)}`);
if (this.flagResolver.isEnabled('filterInvalidClientMetrics')) {
const justNames = badNames.map((r) => r.reason._original.name);
return toggleNames.filter((name) => !justNames.includes(name));
}
}
return toggleNames;
}
async siftMetrics(metrics) {
if (!metrics.length)
return [];
const metricsByToggle = new Map();
for (const m of metrics) {
if (m.yes === 0 && m.no === 0)
continue;
let arr = metricsByToggle.get(m.featureName);
if (!arr) {
arr = [];
metricsByToggle.set(m.featureName, arr);
}
arr.push(m);
}
if (metricsByToggle.size === 0)
return [];
const toggleNames = Array.from(metricsByToggle.keys());
const { validatedToggleNames, unknownToggleNames } = await this.filterExistingToggleNames(toggleNames);
const validatedSet = new Set(validatedToggleNames);
const unknownSet = new Set(unknownToggleNames);
const invalidCount = toggleNames.length - validatedSet.size;
this.logger.debug(`Got ${toggleNames.length} metrics (${invalidCount > 0 ? `${invalidCount} invalid` : 'all valid'}).`);
const unknownFlags = [];
for (const [featureName, group] of metricsByToggle) {
if (unknownSet.has(featureName)) {
for (const m of group) {
unknownFlags.push({
name: featureName,
appName: m.appName,
seenAt: m.timestamp,
environment: m.environment,
});
}
}
}
if (unknownFlags.length) {
const sample = unknownFlags
.slice(0, 10)
.map((f) => `"${f.name}"`)
.join(', ');
this.logger.debug(`Registering ${unknownFlags.length} unknown flags; sample: ${sample}`);
this.unknownFlagsService.register(unknownFlags);
}
const siftedMetrics = [];
for (const [featureName, group] of metricsByToggle) {
if (validatedSet.has(featureName)) {
siftedMetrics.push(...group);
}
}
return siftedMetrics;
}
async registerBulkMetrics(metrics) {
const siftedMetrics = await this.siftMetrics(metrics);
if (siftedMetrics.length === 0)
return;
this.unsavedMetrics = collapseHourlyMetrics([
...this.unsavedMetrics,
...siftedMetrics,
]);
this.lastSeenService.updateLastSeen(siftedMetrics);
this.config.eventBus.emit(CLIENT_METRICS, siftedMetrics);
}
async registerImpactMetrics(impactMetrics) {
try {
const value = await impactMetricsSchema.validateAsync(impactMetrics);
this.impactMetricsTranslator.translateMetrics(value);
}
catch (e) {
// impact metrics should not affect other metrics on failure
this.logger.warn(e);
}
}
async registerClientMetrics(data, clientIp) {
const value = await clientMetricsSchema.validateAsync(data);
if (data.sdkVersion) {
const [sdkName, sdkVersion] = data.sdkVersion.split(':');
const heartbeatEvent = {
sdkName,
sdkVersion,
metadata: {
platformName: data.platformName,
platformVersion: data.platformVersion,
yggdrasilVersion: data.yggdrasilVersion,
specVersion: data.specVersion,
},
};
this.config.eventBus.emit(CLIENT_REGISTER, heartbeatEvent);
}
const clientMetrics = Object.keys(value.bucket.toggles).map((name) => ({
featureName: name,
appName: value.appName,
environment: value.environment ?? 'default',
timestamp: value.bucket.stop, //we might need to approximate between start/stop...
yes: value.bucket.toggles[name].yes ?? 0,
no: value.bucket.toggles[name].no ?? 0,
variants: value.bucket.toggles[name].variants,
}));
if (clientMetrics.length) {
await this.registerBulkMetrics(clientMetrics);
}
}
async bulkAdd() {
if (this.unsavedMetrics.length > 0) {
// Make a copy of `unsavedMetrics` in case new metrics
// arrive while awaiting `batchInsertMetrics`.
const copy = [...this.unsavedMetrics];
this.unsavedMetrics = [];
await this.clientMetricsStoreV2.batchInsertMetrics(copy);
this.config.eventBus.emit(CLIENT_METRICS_ADDED, copy);
}
}
// Overview over usage last "hour" bucket and all applications using the toggle
async getFeatureToggleMetricsSummary(featureName) {
const metrics = await this.clientMetricsStoreV2.getMetricsForFeatureToggle(featureName, 1);
const seenApplications = await this.clientMetricsStoreV2.getSeenAppsForFeatureToggle(featureName);
const groupedMetrics = metrics.reduce((prev, curr) => {
if (prev[curr.environment]) {
prev[curr.environment].yes += curr.yes;
prev[curr.environment].no += curr.no;
}
else {
prev[curr.environment] = {
environment: curr.environment,
timestamp: curr.timestamp,
yes: curr.yes,
no: curr.no,
};
}
return prev;
}, {});
return {
featureName,
lastHourUsage: Object.values(groupedMetrics),
seenApplications,
};
}
async getClientMetricsForToggle(featureName, hoursBack = 24) {
let hours;
let metrics;
if (this.flagResolver.isEnabled('extendedUsageMetrics')) {
// if we're in the daily range we need to add one more day
const normalizedHoursBack = hoursBack > 48 ? hoursBack + 24 : hoursBack;
metrics =
await this.clientMetricsStoreV2.getMetricsForFeatureToggleV2(featureName, normalizedHoursBack);
hours =
hoursBack > 48
? generateDayBuckets(Math.floor(hoursBack / 24))
: generateHourBuckets(hoursBack);
}
else {
metrics =
await this.clientMetricsStoreV2.getMetricsForFeatureToggle(featureName, hoursBack);
hours = generateHourBuckets(hoursBack);
}
const environments = [...new Set(metrics.map((x) => x.environment))];
const applications = [...new Set(metrics.map((x) => x.appName))].slice(0, 100);
const result = environments.flatMap((environment) => {
const environmentMetrics = metrics.filter((metric) => metric.environment === environment);
return applications.flatMap((appName) => {
const applicationMetrics = environmentMetrics.filter((metric) => metric.appName === appName);
return hours.flatMap((hourBucket) => {
const metric = applicationMetrics.find((item) => compareAsc(hourBucket.timestamp, item.timestamp) ===
0);
return (metric || {
timestamp: hourBucket.timestamp,
no: 0,
yes: 0,
appName,
environment,
featureName,
});
});
});
});
return result.sort((a, b) => compareAsc(a.timestamp, b.timestamp));
}
resolveMetricsEnvironment(user, data) {
if (user instanceof ApiUser) {
if (user.environment !== ALL) {
return user.environment;
}
else if (user.environment === ALL && data.environment) {
return data.environment;
}
}
return 'default';
}
resolveUserEnvironment(user) {
if (user instanceof ApiUser && user.environment !== ALL) {
return user.environment;
}
return 'default';
}
}
//# sourceMappingURL=metrics-service-v2.js.map