-// Partial Copyright Jerome Benoit. 2021. All Rights Reserved.
+// Partial Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
-import { CircularArray, DEFAULT_CIRCULAR_ARRAY_SIZE } from '../utils/CircularArray';
-import { IncomingRequestCommand, RequestCommand } from '../types/ocpp/Requests';
-import { PerformanceEntry, PerformanceObserver, performance } from 'perf_hooks';
-import Statistics, { StatisticsData } from '../types/Statistics';
+import { performance, type PerformanceEntry, PerformanceObserver } from 'node:perf_hooks'
+import type { URL } from 'node:url'
+import { parentPort } from 'node:worker_threads'
-import Configuration from '../utils/Configuration';
-import { MessageType } from '../types/ocpp/MessageType';
-import { URL } from 'url';
-import Utils from '../utils/Utils';
-import { WorkerMessageEvents } from '../types/Worker';
-import logger from '../utils/Logger';
-import { parentPort } from 'worker_threads';
+import { secondsToMilliseconds } from 'date-fns'
+import { CircularBuffer } from 'mnemonist'
+import { is, mean, median } from 'rambda'
-export default class PerformanceStatistics {
- private objId: string;
- private performanceObserver: PerformanceObserver;
- private statistics: Statistics;
- private displayInterval: NodeJS.Timeout;
+import { BaseError } from '../exception/index.js'
+import {
+ ConfigurationSection,
+ type IncomingRequestCommand,
+ type LogConfiguration,
+ MapStringifyFormat,
+ MessageType,
+ type RequestCommand,
+ type Statistics,
+ type StatisticsData,
+ type StorageConfiguration,
+ type TimestampedData
+} from '../types/index.js'
+import {
+ buildPerformanceStatisticsMessage,
+ Configuration,
+ Constants,
+ extractTimeSeriesValues,
+ formatDurationSeconds,
+ generateUUID,
+ JSONStringify,
+ logger,
+ logPrefix,
+ max,
+ min,
+ nthPercentile,
+ stdDeviation
+} from '../utils/index.js'
- public constructor(objId: string, URI: URL) {
- this.objId = objId;
- this.initializePerformanceObserver();
- this.statistics = { id: this.objId ?? 'Object id not specified', URI: URI.toString(), createdAt: new Date(), statisticsData: {} };
+export class PerformanceStatistics {
+ private static readonly instances: Map<string, PerformanceStatistics> = new Map<
+ string,
+ PerformanceStatistics
+ >()
+
+ private readonly objId: string | undefined
+ private readonly objName: string | undefined
+ private performanceObserver!: PerformanceObserver
+ private readonly statistics: Statistics
+ private displayInterval?: NodeJS.Timeout
+
+ private constructor (objId: string, objName: string, uri: URL) {
+ this.objId = objId
+ this.objName = objName
+ this.initializePerformanceObserver()
+ this.statistics = {
+ id: this.objId,
+ name: this.objName,
+ uri: uri.toString(),
+ createdAt: new Date(),
+ statisticsData: new Map()
+ }
+ }
+
+ public static getInstance (
+ objId: string | undefined,
+ objName: string | undefined,
+ uri: URL | undefined
+ ): PerformanceStatistics | undefined {
+ if (objId == null) {
+ const errMsg = 'Cannot get performance statistics instance without specifying object id'
+ logger.error(`${PerformanceStatistics.logPrefix()} ${errMsg}`)
+ throw new BaseError(errMsg)
+ }
+ if (objName == null) {
+ const errMsg = 'Cannot get performance statistics instance without specifying object name'
+ logger.error(`${PerformanceStatistics.logPrefix()} ${errMsg}`)
+ throw new BaseError(errMsg)
+ }
+ if (uri == null) {
+ const errMsg = 'Cannot get performance statistics instance without specifying object uri'
+ logger.error(`${PerformanceStatistics.logPrefix()} ${errMsg}`)
+ throw new BaseError(errMsg)
+ }
+ if (!PerformanceStatistics.instances.has(objId)) {
+ PerformanceStatistics.instances.set(objId, new PerformanceStatistics(objId, objName, uri))
+ }
+ return PerformanceStatistics.instances.get(objId)
}
- public static beginMeasure(id: string): string {
- const beginId = 'begin' + id.charAt(0).toUpperCase() + id.slice(1);
- performance.mark(beginId);
- return beginId;
+ public static deleteInstance (objId: string | undefined): boolean {
+ if (objId == null) {
+ const errMsg = 'Cannot delete performance statistics instance without specifying object id'
+ logger.error(`${PerformanceStatistics.logPrefix()} ${errMsg}`)
+ throw new BaseError(errMsg)
+ }
+ return PerformanceStatistics.instances.delete(objId)
}
- public static endMeasure(name: string, beginId: string): void {
- performance.measure(name, beginId);
- performance.clearMarks(beginId);
+ public static beginMeasure (id: string): string {
+ const markId = `${id.charAt(0).toUpperCase()}${id.slice(1)}~${generateUUID()}`
+ performance.mark(markId)
+ return markId
}
- public addRequestStatistic(command: RequestCommand | IncomingRequestCommand, messageType: MessageType): void {
+ public static endMeasure (name: string, markId: string): void {
+ try {
+ performance.measure(name, markId)
+ } catch (error) {
+ if (is(Error, error) && error.message.includes('performance mark has not been set')) {
+ /* Ignore */
+ } else {
+ throw error
+ }
+ }
+ performance.clearMarks(markId)
+ performance.clearMeasures(name)
+ }
+
+ public addRequestStatistic (
+ command: RequestCommand | IncomingRequestCommand,
+ messageType: MessageType
+ ): void {
switch (messageType) {
case MessageType.CALL_MESSAGE:
- if (this.statistics.statisticsData[command] && this.statistics.statisticsData[command].countRequest) {
- this.statistics.statisticsData[command].countRequest++;
+ if (
+ this.statistics.statisticsData.has(command) &&
+ this.statistics.statisticsData.get(command)?.requestCount != null
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ ++this.statistics.statisticsData.get(command)!.requestCount!
} else {
- this.statistics.statisticsData[command] = {} as StatisticsData;
- this.statistics.statisticsData[command].countRequest = 1;
+ this.statistics.statisticsData.set(command, {
+ ...this.statistics.statisticsData.get(command),
+ requestCount: 1
+ })
}
- break;
+ break
case MessageType.CALL_RESULT_MESSAGE:
- if (this.statistics.statisticsData[command]) {
- if (this.statistics.statisticsData[command].countResponse) {
- this.statistics.statisticsData[command].countResponse++;
- } else {
- this.statistics.statisticsData[command].countResponse = 1;
- }
+ if (
+ this.statistics.statisticsData.has(command) &&
+ this.statistics.statisticsData.get(command)?.responseCount != null
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ ++this.statistics.statisticsData.get(command)!.responseCount!
} else {
- this.statistics.statisticsData[command] = {} as StatisticsData;
- this.statistics.statisticsData[command].countResponse = 1;
+ this.statistics.statisticsData.set(command, {
+ ...this.statistics.statisticsData.get(command),
+ responseCount: 1
+ })
}
- break;
+ break
case MessageType.CALL_ERROR_MESSAGE:
- if (this.statistics.statisticsData[command]) {
- if (this.statistics.statisticsData[command].countError) {
- this.statistics.statisticsData[command].countError++;
- } else {
- this.statistics.statisticsData[command].countError = 1;
- }
+ if (
+ this.statistics.statisticsData.has(command) &&
+ this.statistics.statisticsData.get(command)?.errorCount != null
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ ++this.statistics.statisticsData.get(command)!.errorCount!
} else {
- this.statistics.statisticsData[command] = {} as StatisticsData;
- this.statistics.statisticsData[command].countError = 1;
+ this.statistics.statisticsData.set(command, {
+ ...this.statistics.statisticsData.get(command),
+ errorCount: 1
+ })
}
- break;
+ break
default:
- logger.error(`${this.logPrefix()} wrong message type ${messageType}`);
- break;
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ logger.error(`${this.logPrefix()} wrong message type ${messageType}`)
+ break
}
}
- public start(): void {
- this.startLogStatisticsInterval();
- if (Configuration.getPerformanceStorage().enabled) {
- logger.info(`${this.logPrefix()} storage enabled: type ${Configuration.getPerformanceStorage().type}, URI: ${Configuration.getPerformanceStorage().URI}`);
+ public start (): void {
+ this.startLogStatisticsInterval()
+ const performanceStorageConfiguration =
+ Configuration.getConfigurationSection<StorageConfiguration>(
+ ConfigurationSection.performanceStorage
+ )
+ if (performanceStorageConfiguration.enabled === true) {
+ logger.info(
+ `${this.logPrefix()} storage enabled: type ${
+ performanceStorageConfiguration.type
+ }, uri: ${performanceStorageConfiguration.uri}`
+ )
}
}
- public stop(): void {
- if (this.displayInterval) {
- clearInterval(this.displayInterval);
- }
- performance.clearMarks();
- this.performanceObserver?.disconnect();
+ public stop (): void {
+ this.stopLogStatisticsInterval()
+ performance.clearMarks()
+ performance.clearMeasures()
+ this.performanceObserver.disconnect()
}
- public restart(): void {
- this.stop();
- this.start();
+ public restart (): void {
+ this.stop()
+ this.start()
}
- private initializePerformanceObserver(): void {
- this.performanceObserver = new PerformanceObserver((list) => {
- this.addPerformanceEntryToStatistics(list.getEntries()[0]);
- logger.debug(`${this.logPrefix()} '${list.getEntries()[0].name}' performance entry: %j`, list.getEntries()[0]);
- });
- this.performanceObserver.observe({ entryTypes: ['measure'] });
+ private initializePerformanceObserver (): void {
+ this.performanceObserver = new PerformanceObserver(performanceObserverList => {
+ const lastPerformanceEntry = performanceObserverList.getEntries()[0]
+ // logger.debug(
+ // `${this.logPrefix()} '${lastPerformanceEntry.name}' performance entry: %j`,
+ // lastPerformanceEntry
+ // )
+ this.addPerformanceEntryToStatistics(lastPerformanceEntry)
+ })
+ this.performanceObserver.observe({ entryTypes: ['measure'] })
}
- private logStatistics(): void {
- logger.info(this.logPrefix() + ' %j', this.statistics);
+ private logStatistics (): void {
+ logger.info(this.logPrefix(), {
+ ...this.statistics,
+ statisticsData: JSON.parse(
+ JSONStringify(this.statistics.statisticsData, undefined, MapStringifyFormat.object)
+ ) as Map<string | RequestCommand | IncomingRequestCommand, StatisticsData>
+ })
}
- private startLogStatisticsInterval(): void {
- if (Configuration.getLogStatisticsInterval() > 0) {
+ private startLogStatisticsInterval (): void {
+ const logConfiguration = Configuration.getConfigurationSection<LogConfiguration>(
+ ConfigurationSection.log
+ )
+ const logStatisticsInterval =
+ logConfiguration.enabled === true
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ logConfiguration.statisticsInterval!
+ : 0
+ if (logStatisticsInterval > 0 && this.displayInterval == null) {
this.displayInterval = setInterval(() => {
- this.logStatistics();
- }, Configuration.getLogStatisticsInterval() * 1000);
- logger.info(this.logPrefix() + ' logged every ' + Utils.secondsToHHMMSS(Configuration.getLogStatisticsInterval()));
- } else {
- logger.info(this.logPrefix() + ' log interval is set to ' + Configuration.getLogStatisticsInterval().toString() + '. Not logging statistics');
+ this.logStatistics()
+ }, secondsToMilliseconds(logStatisticsInterval))
+ logger.info(
+ `${this.logPrefix()} logged every ${formatDurationSeconds(logStatisticsInterval)}`
+ )
+ } else if (this.displayInterval != null) {
+ logger.info(
+ `${this.logPrefix()} already logged every ${formatDurationSeconds(logStatisticsInterval)}`
+ )
+ } else if (logConfiguration.enabled === true) {
+ logger.info(
+ `${this.logPrefix()} log interval is set to ${logStatisticsInterval}. Not logging statistics`
+ )
}
}
- private median(dataSet: number[]): number {
- if (Array.isArray(dataSet) && dataSet.length === 1) {
- return dataSet[0];
+ private stopLogStatisticsInterval (): void {
+ if (this.displayInterval != null) {
+ clearInterval(this.displayInterval)
+ delete this.displayInterval
}
- const sortedDataSet = dataSet.slice().sort((a, b) => (a - b));
- const middleIndex = Math.floor(sortedDataSet.length / 2);
- if (sortedDataSet.length % 2) {
- return sortedDataSet[middleIndex / 2];
- }
- return (sortedDataSet[(middleIndex - 1)] + sortedDataSet[middleIndex]) / 2;
}
- // TODO: use order statistics tree https://en.wikipedia.org/wiki/Order_statistic_tree
- private percentile(dataSet: number[], percentile: number): number {
- if (percentile < 0 && percentile > 100) {
- throw new RangeError('Percentile is not between 0 and 100');
- }
- if (Utils.isEmptyArray(dataSet)) {
- return 0;
- }
- const sortedDataSet = dataSet.slice().sort((a, b) => (a - b));
- if (percentile === 0) {
- return sortedDataSet[0];
- }
- if (percentile === 100) {
- return sortedDataSet[sortedDataSet.length - 1];
- }
- const percentileIndex = ((percentile / 100) * sortedDataSet.length) - 1;
- if (Number.isInteger(percentileIndex)) {
- return (sortedDataSet[percentileIndex] + sortedDataSet[percentileIndex + 1]) / 2;
+ private addPerformanceEntryToStatistics (entry: PerformanceEntry): void {
+ // Initialize command statistics
+ if (!this.statistics.statisticsData.has(entry.name)) {
+ this.statistics.statisticsData.set(entry.name, {})
}
- return sortedDataSet[Math.round(percentileIndex)];
- }
-
- private stdDeviation(dataSet: number[]): number {
- let totalDataSet = 0;
- for (const data of dataSet) {
- totalDataSet += data;
+ // Update current statistics
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.timeMeasurementCount =
+ (this.statistics.statisticsData.get(entry.name)?.timeMeasurementCount ?? 0) + 1
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.currentTimeMeasurement = entry.duration
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.minTimeMeasurement = min(
+ entry.duration,
+ this.statistics.statisticsData.get(entry.name)?.minTimeMeasurement ?? Number.POSITIVE_INFINITY
+ )
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.maxTimeMeasurement = max(
+ entry.duration,
+ this.statistics.statisticsData.get(entry.name)?.maxTimeMeasurement ?? Number.NEGATIVE_INFINITY
+ )
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.totalTimeMeasurement =
+ (this.statistics.statisticsData.get(entry.name)?.totalTimeMeasurement ?? 0) + entry.duration
+ if (
+ !(
+ this.statistics.statisticsData.get(entry.name)?.measurementTimeSeries instanceof
+ CircularBuffer
+ )
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.measurementTimeSeries =
+ new CircularBuffer<TimestampedData>(
+ Array<TimestampedData>,
+ Constants.DEFAULT_CIRCULAR_BUFFER_CAPACITY
+ )
}
- const dataSetMean = totalDataSet / dataSet.length;
- let totalGeometricDeviation = 0;
- for (const data of dataSet) {
- const deviation = data - dataSetMean;
- totalGeometricDeviation += deviation * deviation;
+ this.statistics.statisticsData.get(entry.name)?.measurementTimeSeries?.push({
+ timestamp: entry.startTime,
+ value: entry.duration
+ })
+ const timeMeasurementValues = extractTimeSeriesValues(
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!
+ .measurementTimeSeries as CircularBuffer<TimestampedData>
+ )
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.avgTimeMeasurement = mean(timeMeasurementValues)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.medTimeMeasurement =
+ median(timeMeasurementValues)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.ninetyFiveThPercentileTimeMeasurement =
+ nthPercentile(timeMeasurementValues, 95)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.stdDevTimeMeasurement = stdDeviation(
+ timeMeasurementValues,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ this.statistics.statisticsData.get(entry.name)!.avgTimeMeasurement
+ )
+ this.statistics.updatedAt = new Date()
+ if (
+ Configuration.getConfigurationSection<StorageConfiguration>(
+ ConfigurationSection.performanceStorage
+ ).enabled === true
+ ) {
+ parentPort?.postMessage(buildPerformanceStatisticsMessage(this.statistics))
}
- return Math.sqrt(totalGeometricDeviation / dataSet.length);
}
- private addPerformanceEntryToStatistics(entry: PerformanceEntry): void {
- let entryName = entry.name;
- // Rename entry name
- const MAP_NAME: Record<string, string> = {};
- if (MAP_NAME[entryName]) {
- entryName = MAP_NAME[entryName];
- }
- // Initialize command statistics
- if (!this.statistics.statisticsData[entryName]) {
- this.statistics.statisticsData[entryName] = {} as StatisticsData;
- }
- // Update current statistics
- this.statistics.updatedAt = new Date();
- this.statistics.statisticsData[entryName].countTimeMeasurement = this.statistics.statisticsData[entryName].countTimeMeasurement ? this.statistics.statisticsData[entryName].countTimeMeasurement + 1 : 1;
- this.statistics.statisticsData[entryName].currentTimeMeasurement = entry.duration;
- this.statistics.statisticsData[entryName].minTimeMeasurement = this.statistics.statisticsData[entryName].minTimeMeasurement ? (this.statistics.statisticsData[entryName].minTimeMeasurement > entry.duration ? entry.duration : this.statistics.statisticsData[entryName].minTimeMeasurement) : entry.duration;
- this.statistics.statisticsData[entryName].maxTimeMeasurement = this.statistics.statisticsData[entryName].maxTimeMeasurement ? (this.statistics.statisticsData[entryName].maxTimeMeasurement < entry.duration ? entry.duration : this.statistics.statisticsData[entryName].maxTimeMeasurement) : entry.duration;
- this.statistics.statisticsData[entryName].totalTimeMeasurement = this.statistics.statisticsData[entryName].totalTimeMeasurement ? this.statistics.statisticsData[entryName].totalTimeMeasurement + entry.duration : entry.duration;
- this.statistics.statisticsData[entryName].avgTimeMeasurement = this.statistics.statisticsData[entryName].totalTimeMeasurement / this.statistics.statisticsData[entryName].countTimeMeasurement;
- Array.isArray(this.statistics.statisticsData[entryName].timeMeasurementSeries) ? this.statistics.statisticsData[entryName].timeMeasurementSeries.push(entry.duration) : this.statistics.statisticsData[entryName].timeMeasurementSeries = new CircularArray<number>(DEFAULT_CIRCULAR_ARRAY_SIZE, entry.duration);
- this.statistics.statisticsData[entryName].medTimeMeasurement = this.median(this.statistics.statisticsData[entryName].timeMeasurementSeries);
- this.statistics.statisticsData[entryName].ninetyFiveThPercentileTimeMeasurement = this.percentile(this.statistics.statisticsData[entryName].timeMeasurementSeries, 95);
- this.statistics.statisticsData[entryName].stdDevTimeMeasurement = this.stdDeviation(this.statistics.statisticsData[entryName].timeMeasurementSeries);
- if (Configuration.getPerformanceStorage().enabled) {
- parentPort.postMessage({ id: WorkerMessageEvents.PERFORMANCE_STATISTICS, data: this.statistics });
- }
+ private static readonly logPrefix = (): string => {
+ return logPrefix(' Performance statistics')
}
- private logPrefix(): string {
- return Utils.logPrefix(` ${this.objId} | Performance statistics`);
+ private readonly logPrefix = (): string => {
+ return logPrefix(` ${this.objName} | Performance statistics`)
}
}