-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(admin-ui-plugin): Add simple metrics support via new metricSumma…
…ry query
- Loading branch information
1 parent
be9b0a4
commit 717d265
Showing
16 changed files
with
639 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,6 +28,7 @@ | |
"typescript": "4.9.5" | ||
}, | ||
"dependencies": { | ||
"date-fns": "^2.30.0", | ||
"fs-extra": "^10.0.0" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import gql from 'graphql-tag'; | ||
|
||
export const adminApiExtensions = gql` | ||
type MetricSummary { | ||
interval: MetricInterval! | ||
type: MetricType! | ||
title: String! | ||
entries: [MetricSummaryEntry!]! | ||
} | ||
enum MetricInterval { | ||
Daily | ||
} | ||
enum MetricType { | ||
OrderCount | ||
OrderTotal | ||
AverageOrderValue | ||
} | ||
type MetricSummaryEntry { | ||
label: String! | ||
value: Float! | ||
} | ||
input MetricSummaryInput { | ||
interval: MetricInterval! | ||
types: [MetricType!]! | ||
refresh: Boolean | ||
} | ||
extend type Query { | ||
""" | ||
Get metrics for the given interval and metric types. | ||
""" | ||
metricSummary(input: MetricSummaryInput): [MetricSummary!]! | ||
} | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { Args, Query, Resolver } from '@nestjs/graphql'; | ||
import { Allow, Ctx, Permission, RequestContext } from '@vendure/core'; | ||
|
||
import { MetricsService } from '../service/metrics.service'; | ||
import { MetricSummary, MetricSummaryInput } from '../types'; | ||
|
||
@Resolver() | ||
export class MetricsResolver { | ||
constructor(private service: MetricsService) {} | ||
|
||
@Query() | ||
@Allow(Permission.ReadOrder) | ||
async metricSummary( | ||
@Ctx() ctx: RequestContext, | ||
@Args('input') input: MetricSummaryInput, | ||
): Promise<MetricSummary[]> { | ||
return this.service.getMetrics(ctx, input); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import { RequestContext } from '@vendure/core'; | ||
|
||
import { MetricData } from '../service/metrics.service'; | ||
import { MetricInterval, MetricSummaryEntry, MetricType } from '../types'; | ||
|
||
/** | ||
* Calculate your metric data based on the given input. | ||
* Be careful with heavy queries and calculations, | ||
* as this function is executed everytime a user views its dashboard | ||
* | ||
*/ | ||
export interface MetricCalculation { | ||
type: MetricType; | ||
|
||
getTitle(ctx: RequestContext): string; | ||
|
||
calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry; | ||
} | ||
|
||
export function getMonthName(monthNr: number): string { | ||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | ||
return monthNames[monthNr]; | ||
} | ||
|
||
/** | ||
* Calculates the average order value per month/week | ||
*/ | ||
export class AverageOrderValueMetric implements MetricCalculation { | ||
readonly type = MetricType.AverageOrderValue; | ||
|
||
getTitle(ctx: RequestContext): string { | ||
return 'average-order-value'; | ||
} | ||
|
||
calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry { | ||
const label = data.date.toISOString(); | ||
if (!data.orders.length) { | ||
return { | ||
label, | ||
value: 0, | ||
}; | ||
} | ||
const total = data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current); | ||
const average = Math.round(total / data.orders.length); | ||
return { | ||
label, | ||
value: average, | ||
}; | ||
} | ||
} | ||
|
||
/** | ||
* Calculates number of orders | ||
*/ | ||
export class OrderCountMetric implements MetricCalculation { | ||
readonly type = MetricType.OrderCount; | ||
|
||
getTitle(ctx: RequestContext): string { | ||
return 'order-count'; | ||
} | ||
|
||
calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry { | ||
const label = data.date.toISOString(); | ||
return { | ||
label, | ||
value: data.orders.length, | ||
}; | ||
} | ||
} | ||
/** | ||
* Calculates order total | ||
*/ | ||
export class OrderTotalMetric implements MetricCalculation { | ||
readonly type = MetricType.OrderTotal; | ||
|
||
getTitle(ctx: RequestContext): string { | ||
return 'order-totals'; | ||
} | ||
|
||
calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry { | ||
const label = data.date.toISOString(); | ||
return { | ||
label, | ||
value: data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current, 0), | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
173 changes: 173 additions & 0 deletions
173
packages/admin-ui-plugin/src/service/metrics.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { assertNever } from '@vendure/common/lib/shared-utils'; | ||
import { | ||
ConfigService, | ||
Logger, | ||
Order, | ||
RequestContext, | ||
TransactionalConnection, | ||
TtlCache, | ||
} from '@vendure/core'; | ||
import { | ||
Duration, | ||
endOfDay, | ||
getDayOfYear, | ||
getISOWeek, | ||
getMonth, | ||
setDayOfYear, | ||
startOfDay, | ||
sub, | ||
} from 'date-fns'; | ||
|
||
import { | ||
AverageOrderValueMetric, | ||
MetricCalculation, | ||
OrderCountMetric, | ||
OrderTotalMetric, | ||
} from '../config/metrics-strategies'; | ||
import { loggerCtx } from '../constants'; | ||
import { MetricInterval, MetricSummary, MetricSummaryEntry, MetricSummaryInput } from '../types'; | ||
|
||
export type MetricData = { | ||
date: Date; | ||
orders: Order[]; | ||
}; | ||
|
||
@Injectable() | ||
export class MetricsService { | ||
private cache = new TtlCache<string, MetricSummary[]>({ ttl: 1000 * 60 * 60 * 24 }); | ||
metricCalculations: MetricCalculation[]; | ||
constructor(private connection: TransactionalConnection, private configService: ConfigService) { | ||
this.metricCalculations = [ | ||
new AverageOrderValueMetric(), | ||
new OrderCountMetric(), | ||
new OrderTotalMetric(), | ||
]; | ||
} | ||
|
||
async getMetrics( | ||
ctx: RequestContext, | ||
{ interval, types, refresh }: MetricSummaryInput, | ||
): Promise<MetricSummary[]> { | ||
// Set 23:59:59.999 as endDate | ||
const endDate = endOfDay(new Date()); | ||
// Check if we have cached result | ||
const cacheKey = JSON.stringify({ | ||
endDate, | ||
types: types.sort(), | ||
interval, | ||
channel: ctx.channel.token, | ||
}); | ||
const cachedMetricList = this.cache.get(cacheKey); | ||
if (cachedMetricList && refresh !== true) { | ||
Logger.verbose(`Returning cached metrics for channel ${ctx.channel.token}`, loggerCtx); | ||
return cachedMetricList; | ||
} | ||
// No cache, calculating new metrics | ||
Logger.verbose( | ||
`No cache hit, calculating ${interval} metrics until ${endDate.toISOString()} for channel ${ | ||
ctx.channel.token | ||
} for all orders`, | ||
loggerCtx, | ||
); | ||
const data = await this.loadData(ctx, interval, endDate); | ||
const metrics: MetricSummary[] = []; | ||
for (const type of types) { | ||
const metric = this.metricCalculations.find(m => m.type === type); | ||
if (!metric) { | ||
continue; | ||
} | ||
// Calculate entry (month or week) | ||
const entries: MetricSummaryEntry[] = []; | ||
data.forEach(dataPerTick => { | ||
entries.push(metric.calculateEntry(ctx, interval, dataPerTick)); | ||
}); | ||
// Create metric with calculated entries | ||
metrics.push({ | ||
interval, | ||
title: metric.getTitle(ctx), | ||
type: metric.type, | ||
entries, | ||
}); | ||
} | ||
this.cache.set(cacheKey, metrics); | ||
return metrics; | ||
} | ||
|
||
async loadData( | ||
ctx: RequestContext, | ||
interval: MetricInterval, | ||
endDate: Date, | ||
): Promise<Map<number, MetricData>> { | ||
let nrOfEntries: number; | ||
let backInTimeAmount: Duration; | ||
const orderRepo = this.connection.getRepository(ctx, Order); | ||
// What function to use to get the current Tick of a date (i.e. the week or month number) | ||
let getTickNrFn: typeof getMonth | typeof getISOWeek; | ||
let maxTick: number; | ||
switch (interval) { | ||
case MetricInterval.Daily: { | ||
nrOfEntries = 30; | ||
backInTimeAmount = { days: nrOfEntries }; | ||
getTickNrFn = getDayOfYear; | ||
maxTick = 365; | ||
break; | ||
} | ||
default: | ||
assertNever(interval); | ||
} | ||
const startDate = startOfDay(sub(endDate, backInTimeAmount)); | ||
const startTick = getTickNrFn(startDate); | ||
// Get orders in a loop until we have all | ||
let skip = 0; | ||
const take = 1000; | ||
let hasMoreOrders = true; | ||
const orders: Order[] = []; | ||
while (hasMoreOrders) { | ||
const query = orderRepo | ||
.createQueryBuilder('order') | ||
.leftJoin('order.channels', 'orderChannel') | ||
.where('orderChannel.id=:channelId', { channelId: ctx.channelId }) | ||
.andWhere('order.orderPlacedAt >= :startDate', { | ||
startDate: startDate.toISOString(), | ||
}) | ||
.skip(skip) | ||
.take(take); | ||
const [items, nrOfOrders] = await query.getManyAndCount(); | ||
orders.push(...items); | ||
Logger.info( | ||
`Fetched orders ${skip}-${skip + take} for channel ${ | ||
ctx.channel.token | ||
} for ${interval} metrics`, | ||
loggerCtx, | ||
); | ||
skip += items.length; | ||
if (orders.length >= nrOfOrders) { | ||
hasMoreOrders = false; | ||
} | ||
} | ||
Logger.verbose( | ||
`Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for ${interval} metrics`, | ||
loggerCtx, | ||
); | ||
const dataPerInterval = new Map<number, MetricData>(); | ||
const ticks = []; | ||
for (let i = 1; i <= nrOfEntries; i++) { | ||
if (startTick + i >= maxTick) { | ||
// make sure we dont go over month 12 or week 52 | ||
ticks.push(startTick + i - maxTick); | ||
} else { | ||
ticks.push(startTick + i); | ||
} | ||
} | ||
ticks.forEach(tick => { | ||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||
const ordersInCurrentTick = orders.filter(order => getTickNrFn(order.orderPlacedAt!) === tick); | ||
dataPerInterval.set(tick, { | ||
orders: ordersInCurrentTick, | ||
date: setDayOfYear(endDate, tick), | ||
}); | ||
}); | ||
return dataPerInterval; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { ID } from '@vendure/core'; | ||
|
||
export type MetricSummary = { | ||
interval: MetricInterval; | ||
type: MetricType; | ||
title: string; | ||
entries: MetricSummaryEntry[]; | ||
}; | ||
|
||
export enum MetricType { | ||
OrderCount = 'OrderCount', | ||
OrderTotal = 'OrderTotal', | ||
AverageOrderValue = 'AverageOrderValue', | ||
} | ||
|
||
export enum MetricInterval { | ||
Daily = 'Daily', | ||
} | ||
|
||
export type MetricSummaryEntry = { | ||
label: string; | ||
value: number; | ||
}; | ||
|
||
export interface MetricSummaryInput { | ||
interval: MetricInterval; | ||
types: MetricType[]; | ||
refresh?: boolean; | ||
} |
Oops, something went wrong.