00PluginProfiler.plugin.js (24277 bytes)
1 /** 2 * @name PluginProfiler 3 * @description Measures the main-thread time of each plugin and shows the CPU and memory use of Discord. Open the plugin settings to see the report. 4 * @version 1.0.0 5 */ 6 7 "use strict"; 8 9 const NAME = "PluginProfiler"; 10 const VERSION = "1.0.0"; 11 // BetterDiscord gives each plugin file this source URL. Old versions do not have "betterdiscord/". 12 const PLUGIN_URL = /^betterdiscord:\/\/(?:betterdiscord\/)?plugins\/(.+)$/; 13 // A timer that a library makes for a plugin belongs to that plugin, not to the library. 14 const LIBRARY_FILES = new Set(["0BDFDB.plugin.js", "0PluginLibrary.plugin.js", "1XenoLib.plugin.js"]); 15 // BetterDiscord and BDFDB call these plugin methods by name, so a wrapper on the instance sees each call. 16 const INSTANCE_HOOKS = /^(observer|onSwitch|process[A-Z]\w*)$/; 17 const OBSERVERS = [ 18 ["MutationObserver", "mutation observer"], 19 ["ResizeObserver", "resize observer"], 20 ["IntersectionObserver", "intersection observer"] 21 ]; 22 const ORIGINAL = Symbol("PluginProfiler.original"); 23 const SAMPLE_MS = 1000; 24 const RESCAN_EVERY = 5; 25 const MAX_SCRIPTS = 500; 26 const NOTE = "The table counts synchronous work in patches, plugin hooks, timers, and observers. " 27 + "Plugin time is wall time on the main thread. It includes waits for disk and for paged-out memory, so it can be more than the CPU time. " 28 + "It does not count event listeners, async work, CSS, or memory per plugin. " 29 + "Reload Discord (Ctrl+R) to also count the timers that plugins make when they start."; 30 31 /** Adds up the self time of each owner. The time of a nested call goes to the nested owner only. */ 32 class Recorder { 33 constructor(now) { 34 this.now = now; 35 this.active = true; 36 this.reset(); 37 } 38 39 reset() { 40 this.startedAt = this.now(); 41 this.owners = new Map(); 42 this.unattributedMs = 0; 43 this.stack = []; 44 } 45 46 /** Calls fn and adds its self time to owner. A null owner marks time that no plugin owns. */ 47 run(owner, kind, fn, thisArg, args) { 48 if (!this.active) return Reflect.apply(fn, thisArg, args); 49 const stack = this.stack; 50 const frame = {start: this.now(), child: 0}; 51 stack.push(frame); 52 try { 53 return Reflect.apply(fn, thisArg, args); 54 } 55 finally { 56 const total = this.now() - frame.start; 57 stack.pop(); 58 if (stack.length) stack[stack.length - 1].child += total; 59 const self = total - frame.child; 60 if (owner === null) this.unattributedMs += self; 61 else this.add(owner, kind, self); 62 } 63 } 64 65 add(owner, kind, ms) { 66 let entry = this.owners.get(owner); 67 if (!entry) this.owners.set(owner, entry = {ms: 0, calls: 0, maxMs: 0, kinds: new Map()}); 68 entry.ms += ms; 69 entry.calls += 1; 70 if (ms > entry.maxMs) entry.maxMs = ms; 71 entry.kinds.set(kind, (entry.kinds.get(kind) || 0) + ms); 72 } 73 74 /** Returns one row for each owner. The row with the most CPU time is first. */ 75 rows() { 76 const elapsed = Math.max(1, this.now() - this.startedAt); 77 return [...this.owners].map(([name, entry]) => ({ 78 name, 79 ms: entry.ms, 80 calls: entry.calls, 81 maxMs: entry.maxMs, 82 percent: entry.ms / elapsed * 100, 83 topKind: [...entry.kinds].sort((a, b) => b[1] - a[1])[0][0] 84 })).sort((a, b) => b.ms - a.ms); 85 } 86 } 87 88 function timed(recorder, owner, kind, fn) { 89 const wrapper = function (...args) { 90 return recorder.run(owner, kind, fn, this, args); 91 }; 92 wrapper[ORIGINAL] = fn; 93 return wrapper; 94 } 95 96 /** Returns the file of each plugin frame on the stack, innermost first. */ 97 function pluginFilesOnStack() { 98 const prepare = Error.prepareStackTrace; 99 const limit = Error.stackTraceLimit; 100 const holder = {}; 101 let sites; 102 try { 103 // Call sites cost less than a formatted stack string. 104 Error.prepareStackTrace = (_, callSites) => callSites; 105 Error.stackTraceLimit = 40; 106 Error.captureStackTrace(holder, pluginFilesOnStack); 107 sites = holder.stack; 108 } 109 finally { 110 Error.prepareStackTrace = prepare; 111 Error.stackTraceLimit = limit; 112 } 113 const files = []; 114 if (!Array.isArray(sites)) return files; 115 for (const site of sites) { 116 const match = PLUGIN_URL.exec(site.getScriptNameOrSourceURL() || ""); 117 if (match) files.push(match[1]); 118 } 119 return files; 120 } 121 122 /** Picks the innermost plugin that is not a library. If only libraries are on the stack, picks the innermost library. */ 123 function ownerFile(files, ownFile) { 124 let library = null; 125 for (const file of files) { 126 if (file === ownFile) continue; 127 if (!LIBRARY_FILES.has(file)) return file; 128 if (!library) library = file; 129 } 130 return library; 131 } 132 133 function hookTimers(scope, recorder, resolveOwner, undo) { 134 for (const name of ["setTimeout", "setInterval"]) { 135 const original = scope[name]; 136 if (typeof original !== "function") continue; 137 const hooked = function (handler, ...rest) { 138 if (typeof handler === "function" && recorder.active) { 139 const owner = recorder.run(NAME, "stack capture", resolveOwner, null, []); 140 if (owner) handler = timed(recorder, owner, "timer", handler); 141 } 142 return Reflect.apply(original, scope, [handler, ...rest]); 143 }; 144 hooked[ORIGINAL] = original; 145 scope[name] = hooked; 146 undo.push(() => { 147 if (scope[name] === hooked) scope[name] = original; 148 }); 149 } 150 } 151 152 function hookObservers(scope, recorder, resolveOwner, undo) { 153 for (const [name, kind] of OBSERVERS) { 154 const Original = scope[name]; 155 if (typeof Original !== "function") continue; 156 const Hooked = class extends Original { 157 constructor(callback, ...rest) { 158 if (typeof callback === "function" && recorder.active) { 159 const owner = recorder.run(NAME, "stack capture", resolveOwner, null, []); 160 if (owner) callback = timed(recorder, owner, kind, callback); 161 } 162 super(callback, ...rest); 163 } 164 }; 165 Object.defineProperty(Hooked, "name", {value: name}); 166 scope[name] = Hooked; 167 undo.push(() => { 168 if (scope[name] === Hooked) scope[name] = Original; 169 }); 170 } 171 } 172 173 /** Wraps the plugin methods that BetterDiscord and BDFDB call by name. */ 174 function hookInstance(recorder, instance, owner, undo) { 175 const seen = new Set(); 176 for (let proto = instance; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) { 177 for (const key of Object.getOwnPropertyNames(proto)) { 178 if (seen.has(key) || !INSTANCE_HOOKS.test(key)) continue; 179 seen.add(key); 180 const current = Object.getOwnPropertyDescriptor(proto, key).value; 181 if (typeof current !== "function" || current[ORIGINAL]) continue; 182 const wrapper = timed(recorder, owner, key.startsWith("process") ? "BDFDB process" : key, current); 183 const hadOwn = proto === instance; 184 try { 185 instance[key] = wrapper; 186 } 187 catch { 188 continue; 189 } 190 undo.push(() => { 191 if (instance[key] !== wrapper) return; 192 if (hadOwn) instance[key] = current; 193 else delete instance[key]; 194 }); 195 } 196 } 197 } 198 199 /** Wraps the callback of one BetterDiscord patch. BetterDiscord reads the callback on each call. */ 200 function wrapPatchChild(recorder, child, owner) { 201 const original = child.callback; 202 if (typeof original !== "function" || original[ORIGINAL]) return false; 203 const kind = `patch ${child.type}`; 204 const wrapper = child.type === "instead" 205 ? function (thisObject, args, next) { 206 // "next" runs the original function or the next plugin. That time is not the time of this plugin. 207 const timedNext = typeof next === "function" ? timed(recorder, null, "original", next) : next; 208 return recorder.run(owner, kind, original, this, [thisObject, args, timedNext]); 209 } 210 : function (...args) { 211 return recorder.run(owner, kind, original, this, args); 212 }; 213 wrapper[ORIGINAL] = original; 214 child.callback = wrapper; 215 return true; 216 } 217 218 function unwrapPatchChild(child) { 219 const original = child.callback && child.callback[ORIGINAL]; 220 if (original) child.callback = original; 221 } 222 223 /** 224 * BDFDB puts all its plugins behind one BetterDiscord patch. It calls each plugin callback 225 * through TimeUtils.suppress with the plugin name, so a hook there splits the time per plugin. 226 */ 227 function hookSuppress(recorder, bdfdb, canonicalName, undo) { 228 const timeUtils = bdfdb && bdfdb.TimeUtils; 229 const original = timeUtils && timeUtils.suppress; 230 if (typeof original !== "function" || original[ORIGINAL]) return false; 231 const hooked = function (callback, strings, config) { 232 const inner = Reflect.apply(original, this, [callback, strings, config]); 233 const name = config && typeof config.name === "string" ? config.name : ""; 234 if (!name || typeof inner !== "function") return inner; 235 // BDFDB uses the name "Discord" when it calls the original Discord function. 236 return timed(recorder, name === "Discord" ? null : canonicalName(name), "BDFDB hook", inner); 237 }; 238 hooked[ORIGINAL] = original; 239 timeUtils.suppress = hooked; 240 undo.push(() => { 241 if (timeUtils.suppress === hooked) timeUtils.suppress = original; 242 }); 243 return true; 244 } 245 246 /** Returns the names that a plugin can use as the caller of its patches. */ 247 function callerNames(addon) { 248 const names = new Set([addon.name, addon.id]); 249 if (typeof addon.name === "string") names.add(addon.name.replace(/\s+/g, "")); 250 if (typeof addon.filename === "string") names.add(addon.filename.replace(/\.plugin\.js$/, "")); 251 try { 252 if (addon.instance && typeof addon.instance.getName === "function") names.add(addon.instance.getName()); 253 } 254 catch { 255 // A broken getName does not stop the scan. 256 } 257 return [...names].filter(name => typeof name === "string" && name); 258 } 259 260 function formatDuration(ms) { 261 const seconds = Math.floor(ms / 1000); 262 return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; 263 } 264 265 function percent(value, digits = 1) { 266 return `${value.toFixed(digits)}%`; 267 } 268 269 function megabytes(bytes) { 270 return `${(bytes / 1048576).toFixed(0)} MB`; 271 } 272 273 const COLUMNS = [ 274 {label: "Plugin", align: "left", value: row => row.name}, 275 {label: "Main thread ms", align: "right", value: row => row.ms.toFixed(1)}, 276 {label: "% of time", align: "right", value: row => row.percent.toFixed(2)}, 277 {label: "Calls", align: "right", value: row => String(row.calls)}, 278 {label: "Worst call ms", align: "right", value: row => row.maxMs.toFixed(1)}, 279 {label: "Main source", align: "left", value: row => row.topKind} 280 ]; 281 282 function summaryLines(snap) { 283 const samples = snap.samples; 284 const frames = snap.frames; 285 const lines = [`Session: ${formatDuration(snap.elapsedMs)}. All plugins: ${snap.pluginMs.toFixed(0)} ms on the main thread, ${percent(snap.pluginMs / snap.elapsedMs * 100, 2)} of the time.`]; 286 if (samples.count) { 287 lines.push(`Discord CPU, all processes: now ${percent(samples.cpuNow)}, average ${percent(samples.cpuSum / samples.count)}, max ${percent(samples.cpuMax)} of one core.`); 288 const types = [...samples.types].map(([type, sum]) => `${type} ${percent(sum / samples.count)}`).join(", "); 289 if (types) lines.push(`Average CPU by process type: ${types}.`); 290 } 291 else { 292 lines.push("Discord CPU: no data yet."); 293 } 294 if (samples.heapNow !== null) { 295 const blink = samples.blinkKB ? ` Blink memory: ${megabytes(samples.blinkKB * 1024)}.` : ""; 296 lines.push(`Renderer JS heap: ${megabytes(samples.heapNow)} (max ${megabytes(samples.heapMax)}).${blink}`); 297 } 298 if (snap.framesSupported) { 299 lines.push(`Long frames (over 50 ms): ${frames.count}, worst ${frames.worstMs.toFixed(0)} ms, blocking time ${frames.blockingMs.toFixed(0)} ms.`); 300 } 301 return lines; 302 } 303 304 const STYLE = { 305 root: {color: "var(--text-default, var(--text-normal, #dbdee1))", fontSize: 14, lineHeight: 1.45, userSelect: "text"}, 306 buttons: {display: "flex", gap: 8, marginBottom: 12}, 307 button: {padding: "6px 14px", borderRadius: 4, border: 0, cursor: "pointer", color: "#fff", background: "var(--button-secondary-background, #4e5058)"}, 308 scroll: {overflowX: "auto", marginTop: 12}, 309 table: {width: "100%", borderCollapse: "collapse", fontVariantNumeric: "tabular-nums"}, 310 th: {padding: "4px 8px", fontWeight: 600, whiteSpace: "nowrap", borderBottom: "1px solid var(--background-modifier-accent, #4e5058)"}, 311 td: {padding: "4px 8px", whiteSpace: "nowrap", borderBottom: "1px solid var(--background-modifier-accent, #3f4147)"}, 312 heading: {fontWeight: 600, marginTop: 14}, 313 note: {opacity: 0.7, fontSize: 12, marginTop: 14} 314 }; 315 316 function Panel({plugin}) { 317 const {createElement: h, useEffect, useState} = BdApi.React; 318 const [, setTick] = useState(0); 319 useEffect(() => { 320 const id = setInterval(() => setTick(n => n + 1), SAMPLE_MS); 321 return () => clearInterval(id); 322 }, []); 323 const snap = plugin.snapshot(); 324 if (!snap) return h("div", {style: STYLE.root}, "Enable the plugin to start the profiler."); 325 const cell = (column, content, base) => h(base === STYLE.th ? "th" : "td", {key: column.label, style: {...base, textAlign: column.align}}, content); 326 return h("div", {style: STYLE.root}, 327 h("div", {style: STYLE.buttons}, 328 h("button", {style: STYLE.button, onClick: () => { plugin.reset(); setTick(n => n + 1); }}, "Reset"), 329 h("button", {style: STYLE.button, onClick: () => plugin.copyReport()}, "Copy report")), 330 summaryLines(snap).map((line, i) => h("div", {key: i}, line)), 331 h("div", {style: STYLE.scroll}, 332 h("table", {style: STYLE.table}, 333 h("thead", null, h("tr", null, COLUMNS.map(column => cell(column, column.label, STYLE.th)))), 334 h("tbody", null, snap.rows.map(row => h("tr", {key: row.name}, COLUMNS.map(column => cell(column, column.value(row), STYLE.td))))))), 335 snap.scripts.length ? h("div", {style: STYLE.heading}, "Scripts in long frames") : null, 336 snap.scripts.map(([label, ms]) => h("div", {key: label}, `${ms.toFixed(0)} ms: ${label}`)), 337 h("div", {style: STYLE.note}, NOTE)); 338 } 339 340 class PluginProfiler { 341 constructor(meta) { 342 this.meta = meta || {}; 343 this.ownFile = this.meta.filename || pluginFilesOnStack()[0] || ""; 344 this.recorder = null; 345 } 346 347 start() { 348 const recorder = this.recorder = new Recorder(() => performance.now()); 349 this.undo = []; 350 this.callers = new Set(); 351 this.fileOwners = new Map(); 352 this.names = new Map(); 353 this.bdfdbGlobal = null; 354 this.ticks = 0; 355 this.resetStats(); 356 const resolveOwner = () => { 357 const file = ownerFile(pluginFilesOnStack(), this.ownFile); 358 return file ? this.fileOwners.get(file) || file : null; 359 }; 360 try { 361 recorder.run(NAME, "setup", () => { 362 hookTimers(window, recorder, resolveOwner, this.undo); 363 hookObservers(window, recorder, resolveOwner, this.undo); 364 this.rescan(); 365 this.observeFrames(); 366 }, null, []); 367 } 368 catch (error) { 369 // BetterDiscord does not call stop() after a failed start(), so remove the hooks here. 370 this.stop(); 371 throw error; 372 } 373 this.timer = setInterval(() => recorder.run(NAME, "sampling", () => this.tick(), null, []), SAMPLE_MS); 374 } 375 376 stop() { 377 if (!this.recorder) return; 378 clearInterval(this.timer); 379 if (this.frameObserver) this.frameObserver.disconnect(); 380 this.frameObserver = null; 381 this.recorder.active = false; 382 for (const caller of this.callers) { 383 for (const child of BdApi.Patcher.getPatchesByCaller(caller)) unwrapPatchChild(child); 384 } 385 while (this.undo.length) { 386 try { 387 this.undo.pop()(); 388 } 389 catch (error) { 390 console.error(NAME, "could not remove a hook", error); 391 } 392 } 393 this.recorder = null; 394 } 395 396 getSettingsPanel() { 397 return BdApi.React.createElement(Panel, {plugin: this}); 398 } 399 400 resetStats() { 401 this.samples = {count: 0, cpuNow: 0, cpuSum: 0, cpuMax: 0, types: new Map(), heapNow: null, heapMax: 0, blinkKB: 0}; 402 this.frames = {count: 0, totalMs: 0, blockingMs: 0, worstMs: 0, scripts: new Map()}; 403 } 404 405 reset() { 406 if (!this.recorder) return; 407 this.recorder.reset(); 408 this.resetStats(); 409 } 410 411 tick() { 412 this.ticks += 1; 413 if (this.ticks % RESCAN_EVERY === 0) this.rescan(); 414 this.sample(); 415 } 416 417 /** Finds new patches, plugin instances, and a new BDFDB. Plugins can patch at any time. */ 418 rescan() { 419 this.fileOwners = new Map(); 420 this.names = new Map(); 421 for (const addon of BdApi.Plugins.getAll()) { 422 if (!addon || addon.name === NAME) continue; 423 if (addon.filename) this.fileOwners.set(addon.filename, addon.name); 424 this.names.set(String(addon.name).toLowerCase(), addon.name); 425 for (const caller of callerNames(addon)) { 426 this.callers.add(caller); 427 for (const child of BdApi.Patcher.getPatchesByCaller(caller)) wrapPatchChild(this.recorder, child, addon.name); 428 } 429 if (addon.instance) hookInstance(this.recorder, addon.instance, addon.name, this.undo); 430 } 431 this.hookBdfdb(); 432 } 433 434 hookBdfdb() { 435 const global = window.BDFDB_Global; 436 if (!global || !global.loaded || global === this.bdfdbGlobal) return; 437 this.bdfdbGlobal = global; 438 let bdfdb; 439 try { 440 // buildPlugin only makes a class and returns it with the library object. 441 bdfdb = global.PluginUtils.buildPlugin({})[1]; 442 } 443 catch { 444 return; 445 } 446 hookSuppress(this.recorder, bdfdb, name => this.names.get(name.toLowerCase()) || name, this.undo); 447 } 448 449 sample() { 450 const samples = this.samples; 451 const utils = window.DiscordNative && window.DiscordNative.processUtils; 452 if (utils && typeof utils.getCurrentCPUUsagePercent === "function") { 453 // Discord adds up the CPU of all its processes. 100 means one full core. 454 const cpu = Number(utils.getCurrentCPUUsagePercent()); 455 if (Number.isFinite(cpu)) { 456 samples.count += 1; 457 samples.cpuNow = cpu; 458 samples.cpuSum += cpu; 459 samples.cpuMax = Math.max(samples.cpuMax, cpu); 460 const types = typeof utils.getCpuUsageElectronProcessTypeDetails === "function" ? utils.getCpuUsageElectronProcessTypeDetails() : null; 461 for (const [type, detail] of Object.entries(types || {})) { 462 const value = Number(detail && detail.percent_cpu); 463 if (Number.isFinite(value)) samples.types.set(type, (samples.types.get(type) || 0) + value); 464 } 465 } 466 } 467 if (performance.memory) { 468 samples.heapNow = performance.memory.usedJSHeapSize; 469 samples.heapMax = Math.max(samples.heapMax, samples.heapNow); 470 } 471 if (utils && typeof utils.getBlinkMemoryInfo === "function") { 472 const blink = utils.getBlinkMemoryInfo(); 473 if (blink && Number.isFinite(blink.allocated)) samples.blinkKB = blink.allocated; 474 } 475 } 476 477 observeFrames() { 478 const types = typeof PerformanceObserver === "function" ? PerformanceObserver.supportedEntryTypes || [] : []; 479 this.framesSupported = types.includes("long-animation-frame"); 480 if (!this.framesSupported) return; 481 const recorder = this.recorder; 482 this.frameObserver = new PerformanceObserver(list => recorder.run(NAME, "frame stats", () => this.addFrames(list.getEntries()), null, [])); 483 this.frameObserver.observe({type: "long-animation-frame"}); 484 } 485 486 addFrames(entries) { 487 const frames = this.frames; 488 for (const entry of entries) { 489 frames.count += 1; 490 frames.totalMs += entry.duration; 491 frames.blockingMs += entry.blockingDuration || 0; 492 frames.worstMs = Math.max(frames.worstMs, entry.duration); 493 for (const script of entry.scripts || []) { 494 const label = this.scriptLabel(script); 495 if (frames.scripts.has(label) || frames.scripts.size < MAX_SCRIPTS) { 496 frames.scripts.set(label, (frames.scripts.get(label) || 0) + script.duration); 497 } 498 } 499 } 500 } 501 502 scriptLabel(script) { 503 const url = script.sourceURL || ""; 504 const match = PLUGIN_URL.exec(url); 505 const file = url.split(/[?#]/)[0].split("/").pop(); 506 // A URL that is not a .js file is the page itself. It can hold a channel ID, so the label does not show it. 507 const where = match ? this.fileOwners.get(match[1]) || match[1] : /\.js$/.test(file) ? file : "page"; 508 const invoker = script.sourceFunctionName || script.invoker || ""; 509 const what = invoker.includes("://") ? "" : invoker; 510 return what ? `${where} (${what})` : where; 511 } 512 513 snapshot() { 514 if (!this.recorder) return null; 515 const elapsedMs = Math.max(1, this.recorder.now() - this.recorder.startedAt); 516 const rows = this.recorder.rows(); 517 const pluginMs = rows.filter(row => row.name !== NAME).reduce((sum, row) => sum + row.ms, 0); 518 const scripts = [...this.frames.scripts].sort((a, b) => b[1] - a[1]).slice(0, 5); 519 return {elapsedMs, rows, pluginMs, samples: this.samples, frames: this.frames, framesSupported: this.framesSupported, scripts}; 520 } 521 522 report() { 523 const snap = this.snapshot(); 524 if (!snap) return `${NAME}: the plugin is not running.`; 525 const table = [COLUMNS.map(column => column.label), ...snap.rows.map(row => COLUMNS.map(column => column.value(row)))]; 526 const widths = COLUMNS.map((_, i) => Math.max(...table.map(cells => cells[i].length))); 527 const lines = [`${NAME} ${VERSION} report`, ...summaryLines(snap), ""]; 528 for (const cells of table) { 529 lines.push(cells.map((text, i) => COLUMNS[i].align === "right" ? text.padStart(widths[i]) : text.padEnd(widths[i])).join(" ").trimEnd()); 530 } 531 if (snap.scripts.length) { 532 lines.push("", "Scripts in long frames:"); 533 for (const [label, ms] of snap.scripts) lines.push(`${ms.toFixed(0).padStart(7)} ms ${label}`); 534 } 535 lines.push("", NOTE); 536 return lines.join("\n"); 537 } 538 539 copyReport() { 540 const text = this.report(); 541 const done = () => BdApi.UI.showToast("Profiler report copied.", {type: "success"}); 542 const fail = error => { 543 console.error(NAME, "could not copy the report", error); 544 BdApi.UI.showToast("Could not copy the report. The console has the error.", {type: "error"}); 545 }; 546 try { 547 const clipboard = window.DiscordNative && window.DiscordNative.clipboard; 548 if (clipboard && typeof clipboard.copy === "function") { 549 clipboard.copy(text); 550 done(); 551 } 552 else { 553 navigator.clipboard.writeText(text).then(done, fail); 554 } 555 } 556 catch (error) { 557 fail(error); 558 } 559 } 560 } 561 562 PluginProfiler.internals = { 563 Recorder, timed, pluginFilesOnStack, ownerFile, hookTimers, hookObservers, hookInstance, 564 wrapPatchChild, unwrapPatchChild, hookSuppress, callerNames, ORIGINAL 565 }; 566 567 module.exports = PluginProfiler;