Yes and no.
TL;DR
First Attempts
You can measure the duration of a specific promise in a specific spot by manually timing its creation and resolution.
const startInstant = performance.now();
fetch("https://httpbin.org/get").then(() => {
const endInstant = performance.now();
console.log(`Duration: ${endInstant-startInstant}ms`);
});
Duration: 447ms
It even works if you await
the promise instead of using a then
callback
const startInstant = performance.now();
await fetch("https://httpbin.org/get");
const endInstant = performance.now();
console.log(`Duration: ${endInstant-startInstant}ms`);
Duration: 288ms
But what if you’re a generic performance library that wants to try and measure the duration of any promise? And do so without requiring users to change any of their application code?
You might try monkeypatching the Promise API itself, by messing with its prototype.
// The generic performance library's code
const old_promise_constructor = Promise;
const old_promise_prototype = Promise.prototype;
Promise = function() {
const promiseObj = new old_promise_constructor(...arguments);
promiseObj.startInstant = performance.now();
return promiseObj;
}
Promise.prototype = old_promise_prototype;
const old_then = Promise.prototype.then;
Promise.prototype.then = function(onFulfilled) {
const startInstant = this.startInstant;
old_then.call(this, function(value) {
const endInstant = performance.now();
console.log(`Start instant: ${startInstant}`);
console.log(`End instant: ${endInstant}`);
console.log(`Duration: ${endInstant-startInstant}ms`);
onFulfilled(value);
});
}
// The (untouched) application code
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('something');
}, 300);
});
myPromise.then((value) => { console.log(`Promise resolved to ${value}`); });
Start instant: 223005
End instant: 223317
Duration: 312ms
Promise resolved to something
Which seems to do the trick…?
The Blocker
But this won’t work if a piece of code is await
-ing a promise, and/or if the promise is “native” (i.e. generated by a built-in function).
const res = await fetch("https://httpbin.org/get");
await
-ing makes it impossible to hook into when a promise resolves. And native promises don’t usually offer a way to hook into their creation.
So is this just impossible?
Node.js to the Rescue
It’s possible in Node thanks to features dedicated to addressing similar problems. One example is the PromiseHooks API, released as of v16.
// The generic performance library's code
import { promiseHooks } from 'node:v8';
promiseHooks.onInit(function (promise) {
promise.startInstant = performance.now();
});
promiseHooks.onSettled(function (promise) {
console.log(`Duration ${performance.now() - promise.startInstant}ms`);
});
// The (untouched) application code
await fetch("https://httpbin.org/get"); //Needs Node v18+ to work without dependencies
Duration 40.9920469969511ms
Duration 0.13454999029636383ms
Duration 41.30363701283932ms
Duration 41.89799699187279ms
Duration 0.24492000043392181ms
Duration 41.59886699914932ms
Duration 228.2701609879732ms
Duration 201.04653500020504ms
Duration 229.50974099338055ms
Duration 1.0617499947547913ms
Duration 297.37966600060463ms
Duration 297.78996600210667ms
Duration 268.15292900800705ms
...
The results imply it’s picking up on a lot of internal promises (likely from Node/v8) on top of the one being await
-ed in the example. That indicates it probably is capturing the duration of all promises, as desired.
(I tried to use the AsyncLocalStorage and AsyncHooks APIs to achieve the same goal, but couldn't figure out how to do it. Here’s what I tried)
But what’s the story for browsers and other JS runtimes (e.g. Deno, Deno Deploy, Cloudflare Workers, Bun)?
But first, why does any of this matter in the first place???
Some Motivation
Application Performance Monitoring (APM) vendors (e.g. Datadog, NewRelic, etc…) often want to be able to record an app’s behavior without needing to modify its source code (a practice known as “auto instrumentation”). Where available, their libraries do this via designated hooks into code execution that the runtime exposes (e.g. via AsyncHooks in Node)
These are natural spots for them to dynamically inject their instrumentation code. But without such points of extension, it can become difficult to auto-instrument applications.
Some History
The Happy Parts
For the JS ecosystem, the first recorded discussion around this I could find was this issue from 2015 on the Chromium bug tracker.
The discussion is about how to add hooks into the v8 JS engine around asynchronous code, to make performance monitoring easier. It also brings up closely related problems, including
- Angular’s usage of zone.js breaking when using not-transpiled-away async/await
- How Chrome DevTools constructs stack traces when asynchronous calls are involved
Out of it came a design doc and ultimately a change to v8 that enabled Node to land its AsyncHooks API in v8 (version 8, not the v8 runtime), the AsyncLocalStorage API in v14, and the PromiseHooks API in v16.
Today, these form the core APIs that allow APM tools to provide auto-instrumentation for Node.js applications.
The Sad Parts
Unfortunately, no adaptation of these changes ever saw a TC39 specification proposal make it off the ground (neither this older one for zones nor a more recent one for async context). This means that they didn’t evolve into a standards-based API for all JS runtimes to consistently implement.
Instead, there’s only Node with its bespoke API surface, with the other runtimes unable to benefit from the same APM tools*.
Looking Forward
Deno is currently planning on creating its own API surface to the same ends, leveraging the same underlying v8 changes that Node originally did.
And doing so would enable OpenTelemetry (which you can imagine as a FOSS version of the vendors’ commercial APM tools) to properly function on Deno, which is actually how I got started down this rabbit hole in the first place. 😅
Out-of-the-box, easy-to-setup instrumentation is a must-have for any modern production application. My hope is that as JS runtimes continue to evolve, they will all continue to improve on this front as well.
*P.S. Through their extensive Node compatibility efforts, Deno now almost fully supports async context just like Node does with AsyncHooks and AsyncLocalStorage
Top comments (0)