Recently Written · git

bd-plugin-profiler

BetterDiscord plugin that measures the main-thread time of each plugin

git clone https://github.com/equwal/bd-plugin-profiler

Log | Files | Refs


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;