"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ProfileReport = exports.extractReadableIndex = exports.formatBytes = exports.formatNumber = exports.pathString = exports.extractJSON = void 0; const clc = require("colorette"); const Table = require("cli-table"); const fs = require("fs"); const _ = require("lodash"); const readline = require("readline"); const error_1 = require("./error"); const logger_1 = require("./logger"); const DATA_LINE_REGEX = /^data: /; const BANDWIDTH_NOTE = "NOTE: The numbers reported here are only estimates of the data" + " payloads from read operations. They are NOT a valid measure of your bandwidth bill."; const SPEED_NOTE = "NOTE: Speeds are reported at millisecond resolution and" + " are not the latencies that clients will see. Pending times" + " are also reported at millisecond resolution. They approximate" + " the interval of time between the instant a request is received" + " and the instant it executes."; const COLLAPSE_THRESHOLD = 25; const COLLAPSE_WILDCARD = ["$wildcard"]; function extractJSON(line, input) { if (!input && !DATA_LINE_REGEX.test(line)) { return null; } else if (!input) { line = line.substring(5); } try { return JSON.parse(line); } catch (e) { return null; } } exports.extractJSON = extractJSON; function pathString(path) { return `/${path ? path.join("/") : ""}`; } exports.pathString = pathString; function formatNumber(num) { const parts = num.toFixed(2).split("."); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); if (+parts[1] === 0) { return parts[0]; } return parts.join("."); } exports.formatNumber = formatNumber; function formatBytes(bytes) { const threshold = 1000; if (Math.round(bytes) < threshold) { return bytes + " B"; } const units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; let u = -1; let formattedBytes = bytes; do { formattedBytes /= threshold; u++; } while (Math.abs(formattedBytes) >= threshold && u < units.length - 1); return formatNumber(formattedBytes) + " " + units[u]; } exports.formatBytes = formatBytes; function extractReadableIndex(query) { if (query.orderBy) { return query.orderBy; } const indexPath = _.get(query, "index.path"); if (indexPath) { return pathString(indexPath); } return ".value"; } exports.extractReadableIndex = extractReadableIndex; class ProfileReport { constructor(tmpFile, outStream, options = {}) { this.tempFile = tmpFile; this.output = outStream; this.options = options; this.state = { outband: {}, inband: {}, writeSpeed: {}, broadcastSpeed: {}, readSpeed: {}, connectSpeed: {}, disconnectSpeed: {}, unlistenSpeed: {}, unindexed: {}, startTime: 0, endTime: 0, opCount: 0, }; } collectUnindexed(data, path) { if (!data.unIndexed) { return; } if (!this.state.unindexed.path) { this.state.unindexed[path] = {}; } const pathNode = this.state.unindexed[path]; const query = data.querySet[0]; const index = JSON.stringify(query.index); if (!pathNode[index]) { pathNode[index] = { times: 0, query: query, }; } const indexNode = pathNode[index]; indexNode.times += 1; } collectSpeedUnpathed(data, opStats) { if (Object.keys(opStats).length === 0) { opStats.times = 0; opStats.millis = 0; opStats.pendingCount = 0; opStats.pendingTime = 0; opStats.rejected = 0; } opStats.times += 1; if (data.hasOwnProperty("millis")) { opStats.millis += data.millis; } if (data.hasOwnProperty("pendingTime")) { opStats.pendingCount++; opStats.pendingTime += data.pendingTime; } if (data.allowed === false) { opStats.rejected += 1; } } collectSpeed(data, path, opType) { if (!opType[path]) { opType[path] = { times: 0, millis: 0, pendingCount: 0, pendingTime: 0, rejected: 0, }; } const node = opType[path]; node.times += 1; if (data.hasOwnProperty("millis")) { node.millis += data.millis; } if (data.hasOwnProperty("pendingTime")) { node.pendingCount++; node.pendingTime += data.pendingTime; } if (data.allowed === false) { node.rejected += 1; } } collectBandwidth(bytes, path, direction) { if (!direction[path]) { direction[path] = { times: 0, bytes: 0, }; } const node = direction[path]; node.times += 1; node.bytes += bytes; } collectRead(data, path, bytes) { this.collectSpeed(data, path, this.state.readSpeed); this.collectBandwidth(bytes, path, this.state.outband); } collectBroadcast(data, path, bytes) { this.collectSpeed(data, path, this.state.broadcastSpeed); this.collectBandwidth(bytes, path, this.state.outband); } collectUnlisten(data, path) { this.collectSpeed(data, path, this.state.unlistenSpeed); } collectConnect(data) { this.collectSpeedUnpathed(data, this.state.connectSpeed); } collectDisconnect(data) { this.collectSpeedUnpathed(data, this.state.disconnectSpeed); } collectWrite(data, path, bytes) { this.collectSpeed(data, path, this.state.writeSpeed); this.collectBandwidth(bytes, path, this.state.inband); } processOperation(data) { if (!this.state.startTime) { this.state.startTime = data.timestamp; } this.state.endTime = data.timestamp; const path = pathString(data.path); this.state.opCount++; switch (data.name) { case "concurrent-connect": this.collectConnect(data); break; case "concurrent-disconnect": this.collectDisconnect(data); break; case "realtime-read": this.collectRead(data, path, data.bytes); break; case "realtime-write": this.collectWrite(data, path, data.bytes); break; case "realtime-transaction": this.collectWrite(data, path, data.bytes); break; case "realtime-update": this.collectWrite(data, path, data.bytes); break; case "listener-listen": this.collectRead(data, path, data.bytes); this.collectUnindexed(data, path); break; case "listener-broadcast": this.collectBroadcast(data, path, data.bytes); break; case "listener-unlisten": this.collectUnlisten(data, path); break; case "rest-read": this.collectRead(data, path, data.bytes); break; case "rest-write": this.collectWrite(data, path, data.bytes); break; case "rest-update": this.collectWrite(data, path, data.bytes); break; default: break; } } collapsePaths(pathedObject, combiner, pathIndex = 1) { if (!this.options.collapse) { return pathedObject; } const allSegments = Object.keys(pathedObject).map((path) => { return path.split("/").filter((s) => { return s !== ""; }); }); const pathSegments = allSegments.filter((segments) => { return segments.length > pathIndex; }); const otherSegments = allSegments.filter((segments) => { return segments.length <= pathIndex; }); if (pathSegments.length === 0) { return pathedObject; } const prefixes = {}; pathSegments.forEach((segments) => { const prefixPath = pathString(segments.slice(0, pathIndex)); const prefixCount = _.get(prefixes, prefixPath, new Set()); prefixes[prefixPath] = prefixCount.add(segments[pathIndex]); }); const collapsedObject = {}; pathSegments.forEach((segments) => { const prefix = segments.slice(0, pathIndex); const prefixPath = pathString(prefix); const prefixCount = _.get(prefixes, prefixPath); const originalPath = pathString(segments); if (prefixCount.size >= COLLAPSE_THRESHOLD) { const tail = segments.slice(pathIndex + 1); const collapsedPath = pathString(prefix.concat(COLLAPSE_WILDCARD).concat(tail)); const currentValue = collapsedObject[collapsedPath]; if (currentValue) { collapsedObject[collapsedPath] = combiner(currentValue, pathedObject[originalPath]); } else { collapsedObject[collapsedPath] = pathedObject[originalPath]; } } else { collapsedObject[originalPath] = pathedObject[originalPath]; } }); otherSegments.forEach((segments) => { const originalPath = pathString(segments); collapsedObject[originalPath] = pathedObject[originalPath]; }); return this.collapsePaths(collapsedObject, combiner, pathIndex + 1); } renderUnindexedData() { const table = new Table({ head: ["Path", "Index", "Count"], style: { head: this.options.isFile ? [] : ["yellow"], border: this.options.isFile ? [] : ["grey"], }, }); const unindexed = this.collapsePaths(this.state.unindexed, (u1, u2) => { _.mergeWith(u1, u2, (p1, p2) => { return { times: p1.times + p2.times, query: p1.query, }; }); }); const paths = Object.keys(unindexed); for (const path of paths) { const indices = Object.keys(unindexed[path]); for (const index of indices) { const data = unindexed[path][index]; const row = [path, extractReadableIndex(data.query), formatNumber(data.times)]; table.push(row); } } return table; } renderBandwidth(pureData) { const table = new Table({ head: ["Path", "Total", "Count", "Average"], style: { head: this.options.isFile ? [] : ["yellow"], border: this.options.isFile ? [] : ["grey"], }, }); const data = this.collapsePaths(pureData, (b1, b2) => { return { bytes: b1.bytes + b2.bytes, times: b1.times + b2.times, }; }); const paths = Object.keys(data).sort((a, b) => { return data[b].bytes - data[a].bytes; }); for (const path of paths) { const bandwidth = data[path]; const row = [ path, formatBytes(bandwidth.bytes), formatNumber(bandwidth.times), formatBytes(bandwidth.bytes / bandwidth.times), ]; table.push(row); } return table; } renderOutgoingBandwidth() { return this.renderBandwidth(this.state.outband); } renderIncomingBandwidth() { return this.renderBandwidth(this.state.inband); } renderUnpathedOperationSpeed(speedData, hasSecurity = false) { const head = ["Count", "Average Execution Speed", "Average Pending Time"]; if (hasSecurity) { head.push("Permission Denied"); } const table = new Table({ head: head, style: { head: this.options.isFile ? [] : ["yellow"], border: this.options.isFile ? [] : ["grey"], }, }); if (Object.keys(speedData).length > 0) { const row = [ speedData.times, formatNumber(speedData.millis / speedData.times) + " ms", formatNumber(speedData.pendingCount === 0 ? 0 : speedData.pendingTime / speedData.pendingCount) + " ms", ]; if (hasSecurity) { row.push(formatNumber(speedData.rejected)); } table.push(row); } return table; } renderOperationSpeed(pureData, hasSecurity = false) { const head = ["Path", "Count", "Average Execution Speed", "Average Pending Time"]; if (hasSecurity) { head.push("Permission Denied"); } const table = new Table({ head: head, style: { head: this.options.isFile ? [] : ["yellow"], border: this.options.isFile ? [] : ["grey"], }, }); const data = this.collapsePaths(pureData, (s1, s2) => { return { times: s1.times + s2.times, millis: s1.millis + s2.millis, pendingCount: s1.pendingCount + s2.pendingCount, pendingTime: s1.pendingTime + s2.pendingTime, rejected: s1.rejected + s2.rejected, }; }); const paths = Object.keys(data).sort((a, b) => { const speedA = data[a].millis / data[a].times; const speedB = data[b].millis / data[b].times; return speedB - speedA; }); for (const path of paths) { const speed = data[path]; const row = [ path, speed.times, formatNumber(speed.millis / speed.times) + " ms", formatNumber(speed.pendingCount === 0 ? 0 : speed.pendingTime / speed.pendingCount) + " ms", ]; if (hasSecurity) { row.push(formatNumber(speed.rejected)); } table.push(row); } return table; } renderReadSpeed() { return this.renderOperationSpeed(this.state.readSpeed, true); } renderWriteSpeed() { return this.renderOperationSpeed(this.state.writeSpeed, true); } renderBroadcastSpeed() { return this.renderOperationSpeed(this.state.broadcastSpeed, false); } renderConnectSpeed() { return this.renderUnpathedOperationSpeed(this.state.connectSpeed, false); } renderDisconnectSpeed() { return this.renderUnpathedOperationSpeed(this.state.disconnectSpeed, false); } renderUnlistenSpeed() { return this.renderOperationSpeed(this.state.unlistenSpeed, false); } async parse(onLine, onClose) { const isFile = this.options.isFile; const tmpFile = this.tempFile; const outStream = this.output; const isInput = this.options.isInput; return new Promise((resolve, reject) => { const rl = readline.createInterface({ input: fs.createReadStream(tmpFile), }); let errored = false; rl.on("line", (line) => { const data = extractJSON(line, isInput); if (!data) { return; } onLine(data); }); rl.on("close", () => { if (errored) { reject(new error_1.FirebaseError("There was an error creating the report.")); } else { const result = onClose(); if (isFile) { outStream.on("finish", () => { resolve(result); }); outStream.end(); } else { resolve(result); } } }); rl.on("error", () => { reject(); }); outStream.on("error", () => { errored = true; rl.close(); }); }); } write(data) { if (this.options.isFile) { this.output.write(data); } else { logger_1.logger.info(data); } } generate() { if (this.options.format === "TXT") { return this.generateText(); } else if (this.options.format === "RAW") { return this.generateRaw(); } else if (this.options.format === "JSON") { return this.generateJson(); } throw new error_1.FirebaseError('Invalid report format expected "TXT", "JSON", or "RAW"'); } generateRaw() { return this.parse(this.writeRaw.bind(this), () => { return null; }); } writeRaw(data) { this.write(JSON.stringify(data) + "\n"); } generateText() { return this.parse(this.processOperation.bind(this), this.outputText.bind(this)); } outputText() { const totalTime = this.state.endTime - this.state.startTime; const isFile = this.options.isFile; const write = this.write.bind(this); const writeTitle = (title) => { if (isFile) { write(title + "\n"); } else { write(clc.bold(clc.yellow(title)) + "\n"); } }; const writeTable = (title, table) => { writeTitle(title); write(table.toString() + "\n"); }; writeTitle(`Report operations collected from ${new Date(this.state.startTime).toISOString()} over ${totalTime} ms.`); writeTitle("Speed Report\n"); write(SPEED_NOTE + "\n\n"); writeTable("Read Speed", this.renderReadSpeed()); writeTable("Write Speed", this.renderWriteSpeed()); writeTable("Broadcast Speed", this.renderBroadcastSpeed()); writeTable("Connect Speed", this.renderConnectSpeed()); writeTable("Disconnect Speed", this.renderDisconnectSpeed()); writeTable("Unlisten Speed", this.renderUnlistenSpeed()); writeTitle("Bandwidth Report\n"); write(BANDWIDTH_NOTE + "\n\n"); writeTable("Downloaded Bytes", this.renderOutgoingBandwidth()); writeTable("Uploaded Bytes", this.renderIncomingBandwidth()); writeTable("Unindexed Queries", this.renderUnindexedData()); } generateJson() { return this.parse(this.processOperation.bind(this), this.outputJson.bind(this)); } outputJson() { const totalTime = this.state.endTime - this.state.startTime; const tableToJson = (table, note) => { const json = { legend: table.options.head, data: [], }; if (note) { json.note = note; } table.forEach((row) => { json.data.push(row); }); return json; }; const json = { totalTime: totalTime, readSpeed: tableToJson(this.renderReadSpeed(), SPEED_NOTE), writeSpeed: tableToJson(this.renderWriteSpeed(), SPEED_NOTE), broadcastSpeed: tableToJson(this.renderBroadcastSpeed(), SPEED_NOTE), connectSpeed: tableToJson(this.renderConnectSpeed(), SPEED_NOTE), disconnectSpeed: tableToJson(this.renderDisconnectSpeed(), SPEED_NOTE), unlistenSpeed: tableToJson(this.renderUnlistenSpeed(), SPEED_NOTE), downloadedBytes: tableToJson(this.renderOutgoingBandwidth(), BANDWIDTH_NOTE), uploadedBytes: tableToJson(this.renderIncomingBandwidth(), BANDWIDTH_NOTE), unindexedQueries: tableToJson(this.renderUnindexedData()), }; this.write(JSON.stringify(json, null, 2)); if (this.options.isFile) { return this.output.path; } return json; } } exports.ProfileReport = ProfileReport;