Chrome supports automatic tracking of stack traces through asynchronous operation. For example, the following code:
async function f1() {
setTimeout(function f2() {
console.trace("hi!")
});
}
function root() {
f1();
}
root();
will show the following stack trace:
f2 @ VM115:5
setTimeout
f1 @ VM115:4
root @ VM115:8
(anonymous) @ VM115:11
To allow custom schedulers to show up in these "stitched together stack traces of the async flow", Chrome exposes a console.createTask(name: string).run(fn: Function)
API which captures the current stack trace when you call console.createTask
, and appends it to stack traces generated by async flows that continue from the .run
callback.
For example, this code:
function f1() {
setTimeout(function f2() {
console.trace("hi!")
});
}
let task;
function setupTask() {
task = console.createTask("MY TASK!");
}
function root() {
task.run(f1);
}
setupTask();
root();
will log
f2 @ VM677:3
setTimeout
f1 @ VM677:2
root @ VM677:13
(anonymous) @ VM677:17
MY TASK
setupTask @ VM677:9
(anonymous) @ VM677:16
Effectively, built-in schedulers like setTimeout
are implemented as if they were doing:
function setTimeout(callback, time) {
const task = console.createTask("setTimeout");
__internalSetTimeout(() => {
task.run(callback);
}, time)
}
console.createTask
and AsyncContext
Given that console.createTask
"tracks async flow spawned by the .run" callback, it could be implemented on top of AsyncContext
:
const __currentTaskTrace = new AsyncContext.Variable({ defaultValue: "" });
function __addTrace(prefix) {
const stack = new Error().stack.split("\n").slice(3).join("\n")
return `${prefix}\n${stack}${__currentTaskTrace.get()}`
}
const cool_console = {
trace(message) {
console.log(__addTrace(message));
},
createTask(name) {
const taskTrace = __addTrace(`\n at ${name}`);
return {
run: fn => __currentTaskTrace.run(taskTrace, fn)
};
}
};
console.createTask
and eventsOne of the most difficult question to answer when it comes to AsyncContext
is what to do when it comes to events: is the logical code flow continuing from where the event listener was registered, or from where some code caused the event to be dispatched?
console.createTask
seems to have different answers depending on the event.
AbortSignal
's abort
(triggered synchronously by JS)let ac = new AbortController();
function register() {
ac.signal.addEventListener("abort", () => console.trace("hi"));
}
function dispatch() {
ac.abort();
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains only dispatch.
HTMLElement
's click
(either triggered synchronously by JS, or by user interaction)function register() {
document.addEventListener("click", () => console.trace("hi"));
}
function dispatch() {
document.body.click();
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
// now, also click manually and see the second trace
The first logged trace contains only dispatch. The logged trace when manually clicking contains neither.
Window
's message
(triggered asynchronously by JS)function register() {
window.addEventListener("message", () => console.trace("hi"));
}
function dispatch() {
window.postMessage("hi");
console.log("after dispatch")
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains dispatch, which has a browser-injected postMessage task as a child.
HTMLStyleElement
's load
(triggered asynchronously by JS)const style = document.createElement("style");
function register() {
style.addEventListener("load", () => console.trace("hi"));
}
function dispatch() {
document.head.appendChild(style)
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains register, which has a browser-injected load task as a child.
HTMLStyleElement
's load
(triggered synchronously through dispatchEvent
)const style = document.createElement("style");
function register() {
style.addEventListener("load", () => console.trace("hi"));
}
function dispatch() {
style.dispatchEvent(new Event("load"))
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains register, which has a browser-injected load task as a child.
foo
on HTMLStyleElement
(triggered synchronously through dispatchEvent
)const style = document.createElement("style");
function register() {
style.addEventListener("foo", () => console.trace("hi"));
}
function dispatch() {
style.dispatchEvent(new Event("foo"))
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains only dispatch.
load
on a custom EventTarget
instance (triggered synchronously through dispatchEvent
)const et = new EventTarget();
function register() {
et.addEventListener("load", () => console.trace("hi"));
}
function dispatch() {
style.dispatchEvent(new Event("load"))
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains register, which has a browser-injected load task as a child.
foo
on a custom EventTarget
instance (triggered synchronously through dispatchEvent
)const et = new EventTarget();
function register() {
et.addEventListener("foo", () => console.trace("hi"));
}
function dispatch() {
style.dispatchEvent(new Event("foo"))
}
console.createTask("register").run(register);
console.createTask("dispatch").run(dispatch);
The logged trace contains only dispatch.
By default, events propagate the current task from the dispatch context, falling back to the root context if the event has been dispatched by the browser.
However for some events, such as "load"
, they propagate the current task from the registration context. Whether this happens depends only on the event name, and not on the EventTarget instance or on how the event is dispatched.
That "load"
and "error"
are the only events for which the registration context is used, as defined at https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/events/event_target.cc;drc=7df47a37470130cdf03467448a1f9d0d228fc47a;l=114.
console.createTask
on top of AsyncContext
To re-explain the console.createTask
propagation on top of AsyncContext
, we must use the dispatch target for event listeners. EventTarget
must special-case "error"
and "load"
to either propagate the full registration context, or to only propagate the the registration-time value relative to console.createTask
.
I would recommend the second, to avoid "error"
/"load"
behaving "weirdly" in the general case.
An .addEventListener
would look as follows, based on the above cool_console
example:
class EventTarget {
#callbacks = new Map();
addEventListener(name, callback) {
// https://github.com/tc39/proposal-upsert
const callbacks = this.#callbacks.emplace(name, []);
if (name === "load" || name === "error") {
callbacks.push({ callback, currentTask: __currentTaskTrace.get() });
} else {
callbacks.push({ callback, currentTask: undefined });
}
}
dispatchEvent(e) {
const callbacks = this.#callbacks.get(e.name) ?? [];
const defaultTask = __currentTaskTrace.get();
for (const { callback, currentTask = defaultTask } of callbacks) {
__currentTaskTrace.run(currentTask, callback.bind(null, e));
}
}
}
Which is similar to how it's already implemented at https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/events/event_target.cc;drc=4b44bd63028a5aa82976dc4d7ed51cb78cbc6891;l=677.