import { Knex } from 'knex'; import { Logger, LogProvider } from '../logger'; import { IClientMetricsEnv, IClientMetricsEnvKey, IClientMetricsStoreV2, } from '../types/stores/client-metrics-store-v2'; import NotFoundError from '../error/notfound-error'; import { startOfHour } from 'date-fns'; interface ClientMetricsEnvTable { feature_name: string; app_name: string; environment: string; timestamp: Date; yes: number; no: number; } const TABLE = 'client_metrics_env'; const fromRow = (row: ClientMetricsEnvTable) => ({ featureName: row.feature_name, appName: row.app_name, environment: row.environment, timestamp: row.timestamp, yes: row.yes, no: row.no, }); const toRow = (metric: IClientMetricsEnv) => ({ feature_name: metric.featureName, app_name: metric.appName, environment: metric.environment, timestamp: startOfHour(metric.timestamp), yes: metric.yes, no: metric.no, }); export class ClientMetricsStoreV2 implements IClientMetricsStoreV2 { private db: Knex; private logger: Logger; constructor(db: Knex, getLogger: LogProvider) { this.db = db; this.logger = getLogger('client-metrics-store-v2.js'); } async get(key: IClientMetricsEnvKey): Promise { const row = await this.db(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`); } async getAll(query: Object = {}): Promise { const rows = await this.db(TABLE) .select('*') .where(query); return rows.map(fromRow); } async exists(key: IClientMetricsEnvKey): Promise { try { await this.get(key); return true; } catch (e) { return false; } } async delete(key: IClientMetricsEnvKey): Promise { return this.db(TABLE) .where({ feature_name: key.featureName, app_name: key.appName, environment: key.environment, timestamp: startOfHour(key.timestamp), }) .del(); } deleteAll(): Promise { return this.db(TABLE).del(); } destroy(): void { // Nothing to do! } // this function will collapse metrics before sending it to the database. async batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise { if (!metrics || metrics.length == 0) { return; } const rows = metrics.map(toRow); const batch = rows.reduce((prev, curr) => { // eslint-disable-next-line prettier/prettier const key = `${curr.feature_name}_${curr.app_name}_${curr.environment}_${curr.timestamp.getTime()}`; if (prev[key]) { prev[key].yes += curr.yes; prev[key].no += curr.no; } else { prev[key] = curr; } return prev; }, {}); // Consider rewriting to SQL batch! const insert = this.db(TABLE) .insert(Object.values(batch)) .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); } async getMetricsForFeatureToggle( featureName: string, hoursBack: number = 24, ): Promise { const rows = await this.db(TABLE) .select('*') .where({ feature_name: featureName }) .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`); return rows.map(fromRow); } async getSeenAppsForFeatureToggle( featureName: string, hoursBack: number = 24, ): Promise { return this.db(TABLE) .distinct() .where({ feature_name: featureName }) .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`) .pluck('app_name') .orderBy('app_name'); } async clearMetrics(hoursAgo: number): Promise { return this.db(TABLE) .whereRaw(`timestamp <= NOW() - INTERVAL '${hoursAgo} hours'`) .del(); } }