UNPKG

@pinelab/vendure-plugin-metrics

Version:

Vendure plugin measuring and visualizing e-commerce metrics

237 lines (236 loc) 11.9 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MetricsService = void 0; const common_1 = require("@nestjs/common"); const core_1 = require("@vendure/core"); const date_fns_1 = require("date-fns"); const constants_1 = require("../constants"); const metric_util_1 = require("./metric-util"); const metric_summary_entity_1 = require("../entities/metric-summary.entity"); const request_service_1 = require("./request-service"); let MetricsService = class MetricsService { constructor(variantService, options, connection, jobQueueService, requestService) { this.variantService = variantService; this.options = options; this.connection = connection; this.jobQueueService = jobQueueService; this.requestService = requestService; this.metricStrategies = this.options.metrics; } async onModuleInit() { this.generateMetricsQueue = await this.jobQueueService.createQueue({ name: 'generate-metrics', process: async (job) => { // Deserialize the RequestContext from the job data const ctx = core_1.RequestContext.deserialize(job.data.ctx); const startDate = new Date(job.data.startDate); const endDate = new Date(job.data.endDate); const variantIds = job.data.variantIds; await this.handleMetricsJob(ctx, startDate, endDate, variantIds).catch((e) => { core_1.Logger.error(`Error processing 'generate-metrics' job: ${e}`, constants_1.loggerCtx); throw e; }); }, }); } /** * Get metrics from cache, or create a job and keep polling the cache */ async getMetrics(ctx, input) { const today = (0, date_fns_1.endOfDay)(new Date()); // Use start of month, because we'd like to see the full results of last years same month const startDate = (0, date_fns_1.startOfMonth)((0, date_fns_1.sub)(today, { months: this.options.displayPastMonths })); const metrics = await this.getAllMetricsFromCache(ctx, startDate, today, input?.variantIds ?? []); if (metrics) { core_1.Logger.info(`Loaded data for ${metrics.length} metrics from cache.`, constants_1.loggerCtx); // All metrics were found in cache, return them return metrics; } // If not in cache, add job to queue await this.generateMetricsQueue.add({ ctx: ctx.serialize(), startDate: startDate.toISOString(), endDate: today.toISOString(), variantIds: input?.variantIds ?? [], }); core_1.Logger.info(`Added 'generate-metrics' job to queue for metric`, constants_1.loggerCtx); // Poll every 1 seconds for the job to finish, with a timeout of 1 minute for (let i = 0; i < 60; i++) { core_1.Logger.info(`Waiting for 'generate-metrics' job to finish... (${i + 1}/60)`, constants_1.loggerCtx); await new Promise((resolve) => setTimeout(resolve, 1000)); const metrics = await this.getAllMetricsFromCache(ctx, startDate, today, input?.variantIds ?? []); if (metrics) { core_1.Logger.info(`Loaded data for ${metrics.length} metrics from cache.`, constants_1.loggerCtx); return metrics; } } throw Error(`Timeout waiting for 'generate-metrics' job to finish`); } /** * Get all metrics from cache. * If any of the metrics is missing, returns undefined */ async getAllMetricsFromCache(ctx, from, to, variantIds) { const metrics = []; for (const metricStrategy of this.metricStrategies) { const cacheKey = this.createCacheKey(ctx, metricStrategy, from, to, variantIds ?? []); // Return cached result if exists let cachedMetricSummary = await this.findMetricSummary(ctx, cacheKey); if (cachedMetricSummary) { metrics.push(cachedMetricSummary.summaryData); } else { // If any of the metrics is missing, return now return; } } if (metrics.length === this.metricStrategies.length) { // All metrics were found in cache, return them return metrics; } } /** * Generate metrics for each strategy, and store them in the cache. */ async handleMetricsJob(ctx, startDate, endDate, variantIds) { // Check cache before processing because another job could have completed in the meantime const metrics = await this.getAllMetricsFromCache(ctx, startDate, endDate, variantIds); if (metrics) { // All metrics were found in cache, return them return metrics; } const start = performance.now(); const variants = await this.variantService.findByIds(ctx, variantIds); const orders = await this.getPlacedOrders(ctx, startDate, endDate, variants); const ordersPerMonth = (0, metric_util_1.groupEntitiesPerMonth)(orders, 'orderPlacedAt', startDate, endDate); const allSessions = await this.requestService.getSessions(ctx, startDate, this.options.sessionLengthInMinutes); await Promise.all(this.metricStrategies.map(async (metricStrategy) => { // Calculate datapoints per 'name', because we could be dealing with a multi line chart const dataPointsPerName = new Map(); ordersPerMonth.forEach((entityMap) => { const sessionsForThisMonth = (0, metric_util_1.getEntitiesForMonth)(allSessions, entityMap.date, 'start'); const calculatedDataPoints = metricStrategy.calculateDataPoints(ctx, entityMap.entities, sessionsForThisMonth, variants); // Loop over datapoint, because we support multi line charts calculatedDataPoints.forEach((dataPoint) => { const entry = dataPointsPerName.get(dataPoint.legendLabel) ?? []; entry.push(dataPoint.value); // Add entry, for example `'product1', [10, 20, 30]` dataPointsPerName.set(dataPoint.legendLabel, entry); }); }); const monthNames = ordersPerMonth.map((d) => (0, metric_util_1.getMonthName)(d.monthNr)); const summary = { code: metricStrategy.code, title: metricStrategy.getTitle(ctx), allowProductSelection: metricStrategy.allowProductSelection, labels: monthNames, series: (0, metric_util_1.mapToSeries)(dataPointsPerName), type: metricStrategy.metricType, }; const cacheKey = this.createCacheKey(ctx, metricStrategy, startDate, endDate, variantIds); await this.saveMetricSummary(ctx, cacheKey, summary); })); const stop = performance.now(); core_1.Logger.info(`Generated metrics for channel ${ctx.channel.token} in ${Math.round(stop - start)}ms`, constants_1.loggerCtx); } /** * Get orders with their lines in the given date range */ async getPlacedOrders(ctx, from, to, variants = []) { let skip = 0; const take = 500; let hasMoreOrders = true; const orders = []; while (hasMoreOrders) { let query = this.connection .getRepository(ctx, core_1.Order) .createQueryBuilder('order') // Join order lines - this replaces the separate OrderLine queries .leftJoinAndSelect('order.lines', 'orderLine') // Join channels as before .leftJoin('order.channels', 'channel') .where('channel.id = :channelId', { channelId: ctx.channelId }) .andWhere('order.orderPlacedAt BETWEEN :fromDate AND :toDate', { fromDate: from.toISOString(), toDate: to.toISOString(), }) .orderBy('order.orderPlacedAt', 'ASC'); // Add variant filtering if variants are specified if (variants.length) { query = query .leftJoinAndSelect('orderLine.productVariant', 'productVariant') .andWhere('productVariant.id IN (:...variantIds)', { variantIds: variants.map((v) => v.id), }); } // Add pagination query = query.skip(skip).take(take); const [items, totalOrders] = await query.getManyAndCount(); orders.push(...items); if (items.length > 0) { const firstDate = items[0].orderPlacedAt?.toISOString().split('T')[0]; const lastDate = items[items.length - 1].orderPlacedAt ?.toISOString() .split('T')[0]; core_1.Logger.info(`Fetched orders ${skip}-${skip + take} (${firstDate} to ${lastDate}) for channel ${ctx.channel.token}`, constants_1.loggerCtx); } skip += items.length; if (orders.length >= totalOrders) { hasMoreOrders = false; } } return orders; } async findMetricSummary(ctx, cacheKey) { return await this.connection .getRepository(ctx, metric_summary_entity_1.MetricSummary) .findOne({ where: { key: cacheKey, channelId: ctx.channelId } }); } async saveMetricSummary(ctx, cacheKey, summary) { // Check if the summary already exists const existingSummary = await this.findMetricSummary(ctx, cacheKey); return await this.connection.getRepository(ctx, metric_summary_entity_1.MetricSummary).save({ id: existingSummary?.id, key: cacheKey, summaryData: summary, channelId: ctx.channelId, }); } /** * Create an identifier to store metrics in the cache. */ createCacheKey(ctx, strategy, from, to, variantIds) { const cacheKeyObject = { code: strategy.code, from: from.toISOString(), to: to.toISOString(), channel: ctx.channel.token, variantIds: [], }; if (strategy.allowProductSelection) { // Only use variantIds for cache key if the strategy allows filtering by variants cacheKeyObject.variantIds = variantIds?.sort() ?? []; } return JSON.stringify(cacheKeyObject); } }; exports.MetricsService = MetricsService; exports.MetricsService = MetricsService = __decorate([ (0, common_1.Injectable)(), __param(1, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)), __metadata("design:paramtypes", [core_1.ProductVariantService, Object, core_1.TransactionalConnection, core_1.JobQueueService, request_service_1.RequestService]) ], MetricsService);