Van az a hibajegy, amelyben a logokból pont az a request ID hiányzik, amelyikre szükség lenne. A handler elején még ott volt, két await-tel később meg eltűnt, mint a felelős a retrospektíven. A Node.js AsyncLocalStorage-a erre ad kényelmes megoldást: kérésenként eltehetjük a trace ID-t, a tenantot vagy a felhasználó azonosítóját, és a mélyen hívott logger nem kap plusz tíz paramétert.
Csakhogy az AsyncLocalStorage nem a HTTP-kérést követi. Azokat az aszinkron erőforrásokat követi, amelyeket az aktív contextben hoztunk létre. A különbség nem nyelvészkedés: egy megosztott EventEmitter, queue vagy saját callback-API mellett ettől lesz a kontextus hiányzó, rosszabb esetben pedig egy másik kérésé.

A jó eset tényleg ennyire kényelmes
Az AsyncLocalStorage a node:async_hooks része, és stabil API. A run() egy store-t rendel a callbackhez; a callbackben létrehozott promise-ok, timer-ek és a belőlük továbbinduló munka ugyanazt a store-t látja. Ez a Node-féle thread-local storage megfelelője, csak itt nem thread, hanem aszinkron végrehajtási lánc van.
Egy HTTP belépési ponton általában elég egyszer meghívni: context.run({ requestId }, () => handler(req, res)). Innen a context.getStore()?.requestId elérhető az adatbázisrétegben, a loggerben és a hibakezelőben is. Nem kell globális változóval zsonglőrködni, ami Node-nál nagy terhelésen nagyjából olyan, mintha minden vendég kabátját egy közös kupacba dobnánk.
Fontos részlet: a store objektumát kérésenként hozzuk létre, és kezeljük olvasható contextként. Ha egy megosztott objektumot módosítunk benne, az AsyncLocalStorage nem ad hozzá mutexet, mély másolatot vagy erkölcsi felügyeletet.
A törés nem az await-nél van
Az alábbi kódot Node 22.22.3-mal futtattam. A listener az egyik esetben az emit() hívásakor aktív contextben fut; a másiknál a regisztráláskor érvényes contextet köti magához.
import { AsyncLocalStorage } from 'node:async_hooks';
import { EventEmitter } from 'node:events';
const context = new AsyncLocalStorage();
const bus = new EventEmitter();
context.run({ requestId: 'plain' }, () => {
bus.once('plain', () => console.log('plain:', context.getStore()));
});
context.run({ requestId: 'bound' }, () => {
bus.once('bound', AsyncLocalStorage.bind(() => {
console.log('bound:', context.getStore());
}));
});
setImmediate(() => {
bus.emit('plain');
bus.emit('bound');
});
// plain: undefined
// bound: { requestId: 'bound' }
Az EventEmitter a listenereket szinkron hívja meg abban a contextben, amelyben az emit() történik. A setImmediate itt a két run()-on kívül jött létre, ezért a sima listenerben nincs store. Na, ezt nem tudtam: nem az számít, hol iratkoztunk fel az eseményre, hanem hogy milyen aszinkron erőforrásból érkezett az esemény.
Az AsyncLocalStorage.bind() erre a rövid, célzott javítás. A függvényt a pillanatnyi végrehajtási contexthez köti, tehát a második listener akkor is a bound store-t látja, amikor a busz később, máshonnan hívja meg. A metódus Node 22.15.0-tól stabil; régebbi támogatott Node-nál ugyanezt egy saját closure-rel, vagy alacsonyabb szinten az AsyncResource-szal lehet megoldani.
Hol bukkan fel ez a valóságban?
Nem a framework saját promise-lánca a gyanús: Express-, Fastify- és adatbázis-kliens kódnál az alap eset általában működik. A határ a saját aszinkron absztrakciónk: processz-szintű EventEmitter, connection pool callbackje, üzenetfogyasztó, régi callbackes SDK vagy egy házilag összerakott job scheduler. Ezek közül nem mind akar és nem is tud egyetlen HTTP-kéréshez tartozni.
Ezért egy globális eseményre ne vigyük rá vakon az aktuális request contextet. Ha a busz egy rendszerállapot-változást közvetít, a listenerhez kötött eredeti request ID hasznos lehet naplózáshoz, de félrevezető lehet üzleti döntéshez. A context legyen megfigyelhetőségi adat, ne rejtett jogosultsági csatorna. A felhasználót, tenantot és tranzakciós határt továbbra is expliciten adjuk át oda, ahol az integritás múlik rajta.
run(), nem enterWith()
A enterWith() csábító, mert nem kell callbacket köré építeni, de a jelenlegi szinkron végrehajtás hátralévő részére is bekapcsolja a store-t, és az abból indított aszinkron munkára is továbbviszi. Egy EventEmitter több listenerénél ez könnyen olyan contextet hagy maga után, amelyet senki nem kért. A Node dokumentációja ezért is a run()-t javasolja, ha nincs nagyon konkrét okunk másra.
Productionben használnám: request ID, trace context és strukturált logolás esetén kifejezetten jó eszköz, mert eltünteti a paraméterlisták egy teljes kategóriáját. De a belépési ponton lezárnám run()-nal, az idegen vagy megosztott callback-határokat teszttel ellenőrizném, és csak ott kötném meg a callbacket, ahol az eredeti kéréshez tartozás valódi jelentéssel bír. Az AsyncLocalStorage nem telepátia; rendes async lánc kell hozzá.