summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/tracer.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/tracer.js')
-rw-r--r--devtools/server/actors/tracer.js502
1 files changed, 502 insertions, 0 deletions
diff --git a/devtools/server/actors/tracer.js b/devtools/server/actors/tracer.js
new file mode 100644
index 0000000000..028d084584
--- /dev/null
+++ b/devtools/server/actors/tracer.js
@@ -0,0 +1,502 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Bug 1827382, as this module can be used from the worker thread,
+// the following JSM may be loaded by the worker loader until
+// we have proper support for ESM from workers.
+const {
+ startTracing,
+ stopTracing,
+ addTracingListener,
+ removeTracingListener,
+ NEXT_INTERACTION_MESSAGE,
+} = require("resource://devtools/server/tracer/tracer.jsm");
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const { tracerSpec } = require("resource://devtools/shared/specs/tracer.js");
+
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+const {
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("devtools/server/actors/object/utils");
+
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+const { JSTRACER_TRACE } = TYPES;
+
+loader.lazyRequireGetter(
+ this,
+ "GeckoProfileCollector",
+ "resource://devtools/server/actors/utils/gecko-profile-collector.js",
+ true
+);
+
+const LOG_METHODS = {
+ STDOUT: "stdout",
+ CONSOLE: "console",
+ PROFILER: "profiler",
+};
+exports.LOG_METHODS = LOG_METHODS;
+const VALID_LOG_METHODS = Object.values(LOG_METHODS);
+
+const CONSOLE_THROTTLING_DELAY = 250;
+
+class TracerActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, tracerSpec);
+ this.targetActor = targetActor;
+ this.sourcesManager = this.targetActor.sourcesManager;
+
+ this.throttledTraces = [];
+ // On workers, we don't have access to setTimeout and can't have throttling
+ this.throttleEmitTraces = isWorker
+ ? this.flushTraces.bind(this)
+ : throttle(this.flushTraces.bind(this), CONSOLE_THROTTLING_DELAY);
+
+ this.geckoProfileCollector = new GeckoProfileCollector();
+ }
+
+ destroy() {
+ this.stopTracing();
+ }
+
+ getLogMethod() {
+ return this.logMethod;
+ }
+
+ /**
+ * Toggle tracing JavaScript.
+ * Meant for the WebConsole command in order to pass advanced
+ * configuration directly to JavaScriptTracer class.
+ *
+ * @param {Object} options
+ * Options used to configure JavaScriptTracer.
+ * See `JavaScriptTracer.startTracing`.
+ * @return {Boolean}
+ * True if the tracer starts, or false if it was stopped.
+ */
+ toggleTracing(options) {
+ if (!this.tracingListener) {
+ this.startTracing(options);
+ return true;
+ }
+ this.stopTracing();
+ return false;
+ }
+
+ /**
+ * Start tracing.
+ *
+ * @param {Object} options
+ * Options used to configure JavaScriptTracer.
+ * See `JavaScriptTracer.startTracing`.
+ */
+ startTracing(options = {}) {
+ if (options.logMethod && !VALID_LOG_METHODS.includes(options.logMethod)) {
+ throw new Error(
+ `Invalid log method '${options.logMethod}'. Only supports: ${VALID_LOG_METHODS}`
+ );
+ }
+ if (options.prefix && typeof options.prefix != "string") {
+ throw new Error("Invalid prefix, only support string type");
+ }
+ if (options.maxDepth && typeof options.maxDepth != "number") {
+ throw new Error("Invalid max-depth, only support numbers");
+ }
+ if (options.maxRecords && typeof options.maxRecords != "number") {
+ throw new Error("Invalid max-records, only support numbers");
+ }
+
+ // When tracing on next user interaction is enabled,
+ // disable logging from workers as this makes the tracer work
+ // against visible documents and is actived per document thread.
+ if (options.traceOnNextInteraction && isWorker) {
+ return;
+ }
+
+ this.logMethod = options.logMethod || LOG_METHODS.STDOUT;
+
+ if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.start();
+ }
+
+ this.tracingListener = {
+ onTracingFrame: this.onTracingFrame.bind(this),
+ onTracingFrameExit: this.onTracingFrameExit.bind(this),
+ onTracingInfiniteLoop: this.onTracingInfiniteLoop.bind(this),
+ onTracingToggled: this.onTracingToggled.bind(this),
+ onTracingPending: this.onTracingPending.bind(this),
+ };
+ addTracingListener(this.tracingListener);
+ this.traceValues = !!options.traceValues;
+ startTracing({
+ global: this.targetActor.window || this.targetActor.workerGlobal,
+ prefix: options.prefix || "",
+ // Enable receiving the `currentDOMEvent` being passed to `onTracingFrame`
+ traceDOMEvents: true,
+ // Enable tracing function arguments as well as returned values
+ traceValues: !!options.traceValues,
+ // Enable tracing only on next user interaction
+ traceOnNextInteraction: !!options.traceOnNextInteraction,
+ // Notify about frame exit / function call returning
+ traceFunctionReturn: !!options.traceFunctionReturn,
+ // Ignore frames beyond the given depth
+ maxDepth: options.maxDepth,
+ // Stop the tracing after a number of top level frames
+ maxRecords: options.maxRecords,
+ });
+ }
+
+ stopTracing() {
+ if (!this.tracingListener) {
+ return;
+ }
+ // Remove before stopping to prevent receiving the stop notification
+ removeTracingListener(this.tracingListener);
+ this.tracingListener = null;
+
+ stopTracing();
+ this.logMethod = null;
+ }
+
+ /**
+ * Queried by THREAD_STATE watcher to send the gecko profiler data
+ * as part of THREAD STATE "stop" resource.
+ *
+ * @return {Object} Gecko profiler profile object.
+ */
+ getProfile() {
+ const profile = this.geckoProfileCollector.stop();
+ // We only open the profile if it contains samples, otherwise it can crash the frontend.
+ if (profile.threads[0].samples.data.length) {
+ return profile;
+ }
+ return null;
+ }
+
+ /**
+ * Be notified by the underlying JavaScriptTracer class
+ * in case it stops by itself, instead of being stopped when the Actor's stopTracing
+ * method is called by the user.
+ *
+ * @param {Boolean} enabled
+ * True if the tracer starts tracing, false it it stops.
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log a message to stdout.
+ */
+ onTracingToggled(enabled, reason) {
+ // stopTracing will clear `logMethod`, so compute this before calling it.
+ const shouldLogToStdout = this.logMethod == LOG_METHODS.STDOUT;
+
+ if (!enabled) {
+ this.stopTracing();
+ }
+ return shouldLogToStdout;
+ }
+
+ /**
+ * Called when "trace on next user interaction" is enabled, to notify the user
+ * that the tracer is initialized but waiting for the user first input.
+ */
+ onTracingPending() {
+ // Delegate to JavaScriptTracer to log to stdout
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ const consoleMessageWatcher = getResourceWatcher(
+ this.targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.emitMessages([
+ {
+ arguments: [NEXT_INTERACTION_MESSAGE],
+ styles: [],
+ level: "jstracer",
+ chromeContext: false,
+ timeStamp: ChromeUtils.dateNow(),
+ },
+ ]);
+ }
+ return false;
+ }
+ return false;
+ }
+
+ onTracingInfiniteLoop() {
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ return true;
+ }
+ if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.stop();
+ return true;
+ }
+ const consoleMessageWatcher = getResourceWatcher(
+ this.targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (!consoleMessageWatcher) {
+ return true;
+ }
+
+ const message =
+ "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!";
+ consoleMessageWatcher.emitMessages([
+ {
+ arguments: [message],
+ styles: [],
+ level: "jstracer",
+ chromeContext: false,
+ timeStamp: ChromeUtils.dateNow(),
+ },
+ ]);
+
+ return false;
+ }
+
+ /**
+ * Called by JavaScriptTracer class when a new JavaScript frame is executed.
+ *
+ * @param {Number} frameId
+ * Unique identifier for the current frame.
+ * This should match a frame notified via onTracingFrameExit.
+ * @param {Debugger.Frame} frame
+ * A descriptor object for the JavaScript frame.
+ * @param {Number} depth
+ * Represents the depth of the frame in the call stack.
+ * @param {String} formatedDisplayName
+ * A human readable name for the current frame.
+ * @param {String} prefix
+ * A string to be displayed as a prefix of any logged frame.
+ * @param {String} currentDOMEvent
+ * If this is a top level frame (depth==0), and we are currently processing
+ * a DOM Event, this will refer to the name of that DOM Event.
+ * Note that it may also refer to setTimeout and setTimeout callback calls.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log the frame to stdout.
+ */
+ onTracingFrame({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix,
+ currentDOMEvent,
+ }) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+ const url = script.source.url;
+
+ // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
+ // columnNumber in 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Ignore blackboxed sources
+ if (
+ this.sourcesManager.isBlackBoxed(
+ url,
+ lineNumber,
+ columnNumber - columnBase
+ )
+ ) {
+ return false;
+ }
+
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ // By returning true, we let JavaScriptTracer class log the message to stdout.
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ // We may receive the currently processed DOM event (if this relates to one).
+ // In this case, log a preliminary message, which looks different to highlight it.
+ if (currentDOMEvent && depth == 0) {
+ // Create a JSTRACER_TRACE resource with a slightly different shape
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ eventName: currentDOMEvent,
+ });
+ }
+
+ let args = undefined;
+ // Log arguments, but only when this feature is enabled as it introduce
+ // some significant overhead in perf as well as memory as it may hold the objects in memory.
+ // Also prevent trying to log function call arguments if we aren't logging a frame
+ // with arguments (e.g. Debugger evaluation frames, when executing from the console)
+ if (this.traceValues && frame.arguments) {
+ args = [];
+ for (let arg of frame.arguments) {
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (arg?.unsafeDereference) {
+ arg = arg.unsafeDereference();
+ }
+ // Instantiate a object actor so that the tools can easily inspect these objects
+ const dbgObj = makeDebuggeeValue(this.targetActor, arg);
+ args.push(createValueGripForTarget(this.targetActor, dbgObj));
+ }
+ }
+
+ // Create a message object that fits Console Message Watcher expectations
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ depth,
+ implementation: frame.implementation,
+ displayName: formatedDisplayName,
+ filename: url,
+ lineNumber,
+ columnNumber: columnNumber - columnBase,
+ sourceId: script.source.id,
+ args,
+ });
+ this.throttleEmitTraces();
+ } else if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.addSample(
+ {
+ // formatedDisplayName has a lambda at the beginning, remove it.
+ name: formatedDisplayName.replace("λ ", ""),
+ url,
+ lineNumber,
+ columnNumber,
+ category: frame.implementation,
+ },
+ depth
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Called by JavaScriptTracer class when a JavaScript frame exits (i.e. a function returns or throw).
+ *
+ * @param {Object} options
+ * @param {Number} options.frameId
+ * Unique identifier for the current frame.
+ * This should match a frame notified via onTracingFrame.
+ * @param {Debugger.Frame} options.frame
+ * A descriptor object for the JavaScript frame.
+ * @param {Number} options.depth
+ * Represents the depth of the frame in the call stack.
+ * @param {String} options.formatedDisplayName
+ * A human readable name for the current frame.
+ * @param {String} options.prefix
+ * A string to be displayed as a prefix of any logged frame.
+ * @param {String} options.why
+ * A string to explain why the function stopped.
+ * See tracer.jsm's FRAME_EXIT_REASONS.
+ * @param {Debugger.Object|primitive} options.rv
+ * The returned value. It can be the returned value, or the thrown exception.
+ * It is either a primitive object, otherwise it is a Debugger.Object for any other JS Object type.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log the frame to stdout.
+ */
+ onTracingFrameExit({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix,
+ why,
+ rv,
+ }) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+ const url = script.source.url;
+
+ // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
+ // columnNumber in 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Ignore blackboxed sources
+ if (
+ this.sourcesManager.isBlackBoxed(
+ url,
+ lineNumber,
+ columnNumber - columnBase
+ )
+ ) {
+ return false;
+ }
+
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ // By returning true, we let JavaScriptTracer class log the message to stdout.
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ let returnedValue = undefined;
+ // Log arguments, but only when this feature is enabled as it introduce
+ // some significant overhead in perf as well as memory as it may hold the objects in memory.
+ if (this.traceValues) {
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (rv?.unsafeDereference) {
+ rv = rv.unsafeDereference();
+ }
+ // Instantiate a object actor so that the tools can easily inspect these objects
+ const dbgObj = makeDebuggeeValue(this.targetActor, rv);
+ returnedValue = createValueGripForTarget(this.targetActor, dbgObj);
+ }
+
+ // Create a message object that fits Console Message Watcher expectations
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ depth,
+ displayName: formatedDisplayName,
+ filename: url,
+ lineNumber,
+ columnNumber: columnNumber - columnBase,
+ sourceId: script.source.id,
+
+ relatedTraceId: frameId,
+ returnedValue,
+ why,
+ });
+ this.throttleEmitTraces();
+ } else if (this.logMethod == LOG_METHODS.PROFILER) {
+ // For now, the profiler doesn't use this.
+ }
+
+ return false;
+ }
+
+ /**
+ * This method is throttled and will notify all pending traces to be logged in the console
+ * via the console message watcher.
+ */
+ flushTraces() {
+ const traceWatcher = getResourceWatcher(this.targetActor, JSTRACER_TRACE);
+ // Ignore the request if the frontend isn't listening to traces for that target.
+ if (!traceWatcher) {
+ return;
+ }
+ const traces = this.throttledTraces;
+ this.throttledTraces = [];
+
+ traceWatcher.emitTraces(traces);
+ }
+}
+exports.TracerActor = TracerActor;