UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

304 lines • 12.2 kB
import NotFoundError from '../../../error/notfound-error.js'; import { endOfDay, startOfHour } from 'date-fns'; import { collapseHourlyMetrics, spreadVariants, } from './collapseHourlyMetrics.js'; import metricsHelper from '../../../util/metrics-helper.js'; import { DB_TIME } from '../../../metric-events.js'; const HOURLY_TABLE = 'client_metrics_env'; const DAILY_TABLE = 'client_metrics_env_daily'; const HOURLY_TABLE_VARIANTS = 'client_metrics_env_variants'; const DAILY_TABLE_VARIANTS = 'client_metrics_env_variants_daily'; const FEATURES_TABLE = 'features'; const fromRow = (row) => ({ featureName: row.feature_name, appName: row.app_name, environment: row.environment, timestamp: row.timestamp, yes: Number(row.yes), no: Number(row.no), }); const toRow = (metric) => ({ feature_name: metric.featureName, app_name: metric.appName, environment: metric.environment, timestamp: startOfHour(metric.timestamp), yes: metric.yes, no: metric.no, }); const toVariantRow = (metric) => ({ feature_name: metric.featureName, app_name: metric.appName, environment: metric.environment, timestamp: startOfHour(metric.timestamp), variant: metric.variant, count: metric.count, }); const variantRowReducer = (acc, tokenRow) => { const { feature_name: featureName, app_name: appName, environment, timestamp, yes, no, variant, count, } = tokenRow; const key = `${featureName}_${appName}_${environment}_${timestamp}_${yes}_${no}`; if (!acc[key]) { acc[key] = { featureName, appName, environment, timestamp, yes: Number(yes), no: Number(no), variants: {}, }; } if (variant) { acc[key].variants[variant] = count; } return acc; }; const variantRowReducerV2 = (acc, tokenRow) => { const { feature_name: featureName, app_name: appName, environment, timestamp, date, yes, no, variant, count, } = tokenRow; const key = `${featureName}_${appName}_${environment}_${timestamp || date}_${yes}_${no}`; if (!acc[key]) { acc[key] = { featureName, appName, environment, timestamp: timestamp || endOfDay(date), yes: Number(yes), no: Number(no), variants: {}, }; } if (variant) { acc[key].variants[variant] = Number(count); } return acc; }; export class ClientMetricsStoreV2 { constructor(db, eventBus, _getLogger, _flagResolver) { this.db = db; this.metricTimer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, { store: 'client-metrics', action, }); } async get(key) { const row = await this.db(HOURLY_TABLE) .where({ feature_name: key.featureName, app_name: key.appName, environment: key.environment, timestamp: startOfHour(key.timestamp), }) .first(); if (row) { return fromRow(row); } throw new NotFoundError(`Could not find metric`); } //TODO: Consider moving this to a specific feature store async getFeatureFlagNames() { return this.db(FEATURES_TABLE).distinct('name').pluck('name'); } async getAll(query = {}) { const rows = await this.db(HOURLY_TABLE) .select('*') .where(query); return rows.map(fromRow); } async exists(key) { try { await this.get(key); return true; } catch (_e) { return false; } } async delete(key) { return this.db(HOURLY_TABLE) .where({ feature_name: key.featureName, app_name: key.appName, environment: key.environment, timestamp: startOfHour(key.timestamp), }) .del(); } deleteAll() { return this.db(HOURLY_TABLE).del(); } destroy() { // Nothing to do! } // this function will collapse metrics before sending it to the database. async batchInsertMetrics(metrics) { if (!metrics || metrics.length === 0) { return; } const rows = collapseHourlyMetrics(metrics).map(toRow); // Sort the rows to avoid deadlocks const sortedRows = rows.sort((a, b) => a.feature_name.localeCompare(b.feature_name) || a.app_name.localeCompare(b.app_name) || a.environment.localeCompare(b.environment)); // Consider rewriting to SQL batch! const insert = this.db(HOURLY_TABLE) .insert(sortedRows) .toQuery(); const query = `${insert.toString()} ON CONFLICT (feature_name, app_name, environment, timestamp) DO UPDATE SET "yes" = "client_metrics_env"."yes" + EXCLUDED.yes, "no" = "client_metrics_env"."no" + EXCLUDED.no`; await this.db.raw(query); const variantRows = spreadVariants(metrics).map(toVariantRow); // Sort the rows to avoid deadlocks const sortedVariantRows = variantRows.sort((a, b) => a.feature_name.localeCompare(b.feature_name) || a.app_name.localeCompare(b.app_name) || a.environment.localeCompare(b.environment) || a.variant.localeCompare(b.variant)); if (sortedVariantRows.length > 0) { const insertVariants = this.db(HOURLY_TABLE_VARIANTS) .insert(sortedVariantRows) .toQuery(); const variantsQuery = `${insertVariants.toString()} ON CONFLICT (feature_name, app_name, environment, timestamp, variant) DO UPDATE SET "count" = "client_metrics_env_variants"."count" + EXCLUDED.count`; await this.db.raw(variantsQuery); } } async getMetricsForFeatureToggle(featureName, hoursBack = 24) { const rows = await this.db(HOURLY_TABLE) .select([`${HOURLY_TABLE}.*`, 'variant', 'count']) .leftJoin(HOURLY_TABLE_VARIANTS, function () { this.on(`${HOURLY_TABLE_VARIANTS}.feature_name`, `${HOURLY_TABLE}.feature_name`) .on(`${HOURLY_TABLE_VARIANTS}.app_name`, `${HOURLY_TABLE}.app_name`) .on(`${HOURLY_TABLE_VARIANTS}.environment`, `${HOURLY_TABLE}.environment`) .on(`${HOURLY_TABLE_VARIANTS}.timestamp`, `${HOURLY_TABLE}.timestamp`); }) .where(`${HOURLY_TABLE}.feature_name`, featureName) .andWhereRaw(`${HOURLY_TABLE}.timestamp >= NOW() - INTERVAL '${hoursBack} hours'`); const tokens = rows.reduce(variantRowReducer, {}); return Object.values(tokens); } async getMetricsForFeatureToggleV2(featureName, hoursBack = 24) { const mainTable = hoursBack <= 48 ? HOURLY_TABLE : DAILY_TABLE; const variantsTable = hoursBack <= 48 ? HOURLY_TABLE_VARIANTS : DAILY_TABLE_VARIANTS; const dateTime = hoursBack <= 48 ? 'timestamp' : 'date'; const rows = await this.db(mainTable) .select([`${mainTable}.*`, 'variant', 'count']) .leftJoin(variantsTable, function () { this.on(`${variantsTable}.feature_name`, `${mainTable}.feature_name`) .on(`${variantsTable}.app_name`, `${mainTable}.app_name`) .on(`${variantsTable}.environment`, `${mainTable}.environment`) .on(`${variantsTable}.${dateTime}`, `${mainTable}.${dateTime}`); }) .where(`${mainTable}.feature_name`, featureName) .andWhereRaw(`${mainTable}.${dateTime} >= NOW() - INTERVAL '${hoursBack} hours'`); const tokens = rows.reduce(variantRowReducerV2, {}); return Object.values(tokens); } async getSeenAppsForFeatureToggle(featureName, hoursBack = 24) { return this.db(HOURLY_TABLE) .distinct() .where({ feature_name: featureName }) .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`) .pluck('app_name') .orderBy('app_name'); } async getSeenTogglesForApp(appName, hoursBack = 24) { return this.db(HOURLY_TABLE) .distinct() .where({ app_name: appName }) .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`) .pluck('feature_name') .orderBy('feature_name'); } async clearMetrics(hoursAgo) { return this.db(HOURLY_TABLE) .whereRaw(`timestamp <= NOW() - INTERVAL '${hoursAgo} hours'`) .del(); } async clearDailyMetrics(daysAgo) { return this.db(DAILY_TABLE) .whereRaw(`date <= CURRENT_DATE - INTERVAL '${daysAgo} days'`) .del(); } async countPreviousDayHourlyMetricsBuckets() { const enabledCountQuery = this.db(HOURLY_TABLE) .whereRaw("timestamp >= CURRENT_DATE - INTERVAL '1 day'") .andWhereRaw('timestamp < CURRENT_DATE') .count() .first(); const variantCountQuery = this.db(HOURLY_TABLE_VARIANTS) .whereRaw("timestamp >= CURRENT_DATE - INTERVAL '1 day'") .andWhereRaw('timestamp < CURRENT_DATE') .count() .first(); const [enabledCount, variantCount] = await Promise.all([ enabledCountQuery, variantCountQuery, ]); return { enabledCount: Number(enabledCount?.count || 0), variantCount: Number(variantCount?.count || 0), }; } async countPreviousDayMetricsBuckets() { const enabledCountQuery = this.db(DAILY_TABLE) .whereRaw("date >= CURRENT_DATE - INTERVAL '1 day'") .andWhereRaw('date < CURRENT_DATE') .count() .first(); const variantCountQuery = this.db(DAILY_TABLE_VARIANTS) .whereRaw("date >= CURRENT_DATE - INTERVAL '1 day'") .andWhereRaw('date < CURRENT_DATE') .count() .first(); const [enabledCount, variantCount] = await Promise.all([ enabledCountQuery, variantCountQuery, ]); return { enabledCount: Number(enabledCount?.count || 0), variantCount: Number(variantCount?.count || 0), }; } // aggregates all hourly metrics from a previous day into daily metrics async aggregateDailyMetrics() { const stopTimer = this.metricTimer('aggregateDailyMetrics'); const rawQuery = ` INSERT INTO ${DAILY_TABLE} (feature_name, app_name, environment, date, yes, no) SELECT feature_name, app_name, environment, CURRENT_DATE - INTERVAL '1 day' as date, SUM(yes) as yes, SUM(no) as no FROM ${HOURLY_TABLE} WHERE timestamp >= CURRENT_DATE - INTERVAL '1 day' AND timestamp < CURRENT_DATE GROUP BY feature_name, app_name, environment ON CONFLICT (feature_name, app_name, environment, date) DO UPDATE SET yes = EXCLUDED.yes, no = EXCLUDED.no; `; const rawVariantsQuery = ` INSERT INTO ${DAILY_TABLE_VARIANTS} (feature_name, app_name, environment, date, variant, count) SELECT feature_name, app_name, environment, CURRENT_DATE - INTERVAL '1 day' as date, variant, SUM(count) as count FROM ${HOURLY_TABLE_VARIANTS} WHERE timestamp >= CURRENT_DATE - INTERVAL '1 day' AND timestamp < CURRENT_DATE GROUP BY feature_name, app_name, environment, variant ON CONFLICT (feature_name, app_name, environment, date, variant) DO UPDATE SET count = EXCLUDED.count; `; // have to be run serially since variants table has FK on yes/no metrics await this.db.raw(rawQuery); await this.db.raw(rawVariantsQuery); stopTimer(); } } //# sourceMappingURL=client-metrics-store-v2.js.map