@pinelab/vendure-plugin-metrics
Version:
Vendure plugin measuring and visualizing e-commerce metrics
237 lines (236 loc) • 11.9 kB
JavaScript
;
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);