import { createObjectCsvWriter } from 'csv-writer' import fs from 'node:fs' import fsPromise from 'node:fs/promises' import type { EntryType } from 'node:perf_hooks' import { performance, PerformanceObserver } from 'node:perf_hooks' import util from 'node:util' import worker from 'node:worker_threads' import path from 'node:path' import { arch, hostname, platform, type, version } from 'node:os' import { BStackLogger } from '../../bstackLogger.js' import { EDS_URL, PERF_MEASUREMENT_ENV } from '../../constants.js' import fetchWrap from '../../fetchWrapper.js' import type { CsvWriter } from 'csv-writer/src/lib/csv-writer.js' import type { ObjectMap } from 'csv-writer/src/lib/lang/object.js' import type { Browser } from 'webdriverio' type PerformanceDetails = { success?: true, failure?: string, testName?: string, worker?: string | number, command?: string, hookType?: string, platform?: string | number } export default class PerformanceTester { static _observer: PerformanceObserver static _csvWriter: CsvWriter> private static _events: PerformanceEntry[] = [] private static _measuredEvents: PerformanceEntry[] = [] static started = false static details: { [key: string]: PerformanceDetails } = {} static eventsMap: { [key: string]: number } = {} static browser?: Browser static scenarioThatRan: string[] static jsonReportDirName = 'performance-report' static jsonReportDirPath = path.join(process.cwd(), 'logs', this.jsonReportDirName) static jsonReportFileName = `${this.jsonReportDirPath}/performance-report-${PerformanceTester.getProcessId()}.json` static startMonitoring(csvName: string = 'performance-report.csv') { // Create performance-report dir if not exists already if (!fs.existsSync(this.jsonReportDirPath)) { fs.mkdirSync(this.jsonReportDirPath, { recursive: true }) } this._observer = new PerformanceObserver(list => { list.getEntries() .filter((entry) => entry.entryType === 'measure') .forEach(entry => { let finalEntry = entry finalEntry = entry.toJSON() if (this.details[entry.name]) { finalEntry = Object.assign(finalEntry, this.details[entry.name]) } delete this.details[entry.name] this._measuredEvents.push(finalEntry) } ) if (process.env[PERF_MEASUREMENT_ENV]) { list.getEntries().forEach((entry) => this._events.push(entry)) } }) const entryTypes: EntryType[] = ['measure'] if (process.env[PERF_MEASUREMENT_ENV]) { entryTypes.push('function') } this._observer.observe({ buffered: true, entryTypes: entryTypes }) this.started = true if (process.env[PERF_MEASUREMENT_ENV]) { this._csvWriter = createObjectCsvWriter({ path: csvName, header: [ { id: 'name', title: 'Function Name' }, { id: 'time', title: 'Execution Time (ms)' } ] }) } } static calculateTimes(methods: string[]) : number { const times: { [key: string]: number } = {} this._events.map((entry) => { if (!times[entry.name]) { times[entry.name] = 0 } times[entry.name] += entry.duration }) const timeTaken = methods.reduce((a, c) => { return times[c] + (a || 0) }, 0) BStackLogger.debug(`Time for ${methods} is ${timeTaken}`) return timeTaken } static async stopAndGenerate(filename: string = 'performance-own.html') { if (!this.started) { return } try { const eventsJson = JSON.stringify(this._measuredEvents) // remove enclosing array and add a trailing comma so that we // dont need to both read and then write the file, we can use append instead const finalJSONStr = eventsJson.slice(1, -1) + ',' await fsPromise.appendFile(this.jsonReportFileName, finalJSONStr) } catch (er) { BStackLogger.debug(`Failed to write events of the worker to ${this.jsonReportFileName}: ${util.format(er)}`) } this._observer.disconnect() if (!process.env[PERF_MEASUREMENT_ENV]) { return } await PerformanceTester.sleep(2000) // Wait to 2s just to finish any running callbacks for timerify this.started = false this.generateCSV(this._events) const content = this.generateReport(this._events) const dir = path.join(process.cwd(), filename) try { await fsPromise.writeFile(dir, content) BStackLogger.info(`Performance report is at ${path}`) } catch (err) { BStackLogger.error(`Error in writing html ${util.format(err)}`) } } static generateReport(entries: PerformanceEntry[]) { let html = 'Performance Report' html += '

Performance Report

' html += '' entries.forEach((entry) => { html += `` }) html += '
Function NameDuration (ms)
${entry.name}${entry.duration}
' return html } static generateCSV(entries: PerformanceEntry[]) { const times: { [key: string]: number } = {} entries.map((entry) => { if (!times[entry.name]) { times[entry.name] = 0 } times[entry.name] += entry.duration return { name: entry.name, time: entry.duration } }) const dat = Object.entries(times).map(([key, value]) => { return { name: key, time: value } }) this._csvWriter.writeRecords(dat) .then(() => BStackLogger.info('Performance CSV report generated successfully')) .catch((error: Error) => console.error(error)) } static Measure(label: string, details: PerformanceDetails = {}) { const self = this return ( target: object, key: string | symbol, descriptor: TypedPropertyDescriptor) => { const originalMethod: Function|undefined = descriptor.value if (descriptor.value) { descriptor.value = function(...args: object[]) { return PerformanceTester.measure.apply(self, [label, originalMethod as Function, { methodName: key.toString(), ...details }, args, this]) } } } } static measureWrapper(name: string, fn: Function, details: PerformanceDetails = {}) { const self = this details.worker = PerformanceTester.getProcessId() details.testName = PerformanceTester.scenarioThatRan && PerformanceTester.scenarioThatRan[PerformanceTester.scenarioThatRan.length - 1] details.platform = PerformanceTester.browser?.sessionId return function (...args: (object|boolean|undefined|null|string)[]) { return self.measure(name, fn, details, args) } } static isEnabled() { return !(process.env.BROWSERSTACK_SDK_INSTRUMENTATION === 'false') } static measure(label: string, fn: Function, details = {}, args?: (object|boolean|undefined|null|string)[], thisArg: object|null = null) { if (!this.started || !this.isEnabled()) { return fn.apply(thisArg, args) } PerformanceTester.start(label) if (this.details) { (this.details[label] = details) } try { const returnVal = fn.apply(thisArg, args) if (returnVal instanceof Promise) { return new Promise((resolve, reject) => { returnVal .then(v => { PerformanceTester.end(label) resolve(v) }).catch(e => { PerformanceTester.end(label, false, util.format(e)) reject(e) }) }) } PerformanceTester.end(label) return returnVal } catch (er) { PerformanceTester.end(label, false, util.format(er)) throw er } } static start(event: string) { const finalEvent = event + '-start' if (this.eventsMap[finalEvent]) {return} performance.mark(finalEvent) this.eventsMap[finalEvent] = 1 } static end(event: string, success = true, failure?: string | unknown, details = {}) { performance.mark(event + '-end') performance.measure(event, event + '-start', event + '-end') this.details[event] = Object.assign({ success, failure: util.format(failure) }, Object.assign(Object.assign({ worker: PerformanceTester.getProcessId(), platform: PerformanceTester.browser?.sessionId, testName: PerformanceTester.scenarioThatRan && PerformanceTester.scenarioThatRan[PerformanceTester.scenarioThatRan.length - 1] }, details), this.details[event] || {})) } static getProcessId() { return `${process.pid}-${worker.threadId}` } static sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)) static async uploadEventsData() { try { let measures = [] if (await fsPromise.access(this.jsonReportDirPath).then(() => true).catch(() => false)) { const files = (await fsPromise.readdir(this.jsonReportDirPath)).map(file => path.resolve(this.jsonReportDirPath, file)) measures = (await Promise.all(files.map((file) => fsPromise.readFile(file, 'utf-8')))).map(el => `[${el.slice(0, -1)}]`).map(el => JSON.parse(el)).flat() } if (this._measuredEvents.length > 0) { measures = measures.concat(this._measuredEvents) } const date = new Date() // yyyy-MM-dd'T'HH:mm:ss.SSSSSS Z const options: Intl.DateTimeFormatOptions = { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, // To include microseconds hour12: false } // Format the date and replace the default separator for time zone const formattedDate = new Intl.DateTimeFormat('en-GB', options) .formatToParts(date) .map(({ type, value }) => type === 'timeZoneName' ? 'Z' : value) .join('') .replace(',', 'T') const payload = { event_type: 'sdk_events', data: { testhub_uuid: process.env.PERF_TESTHUB_UUID || process.env.SDK_RUN_ID, created_day: formattedDate, event_name: 'SDKFeaturePerformance', user_data: process.env.PERF_USER_NAME, host_info: JSON.stringify({ hostname: hostname(), platform: platform(), type: type(), version: version(), arch: arch() }), event_json: { measures: measures, sdkRunId: process.env.SDK_RUN_ID } } } const result = await fetchWrap(`${EDS_URL}/send_sdk_events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }) BStackLogger.debug(`Successfully uploaded performance events ${util.format(await result.text())}`) } catch (er) { BStackLogger.debug(`Failed to upload performance events ${util.format(er)}`) } try { if (await fsPromise.access(this.jsonReportDirPath).then(() => true, () => false)) { const files = await fsPromise.readdir(this.jsonReportDirPath) for (const file of files) { await fsPromise.unlink(path.join(this.jsonReportDirPath, file)) } } } catch (er) { BStackLogger.debug(`Failed to delete performance related files ${util.format(er)}`) } } }