Correlation IDs في Node.js باستخدام
١ يوليو ٢٠٢٦
يمكنك إضافة معرف ارتباط (correlation ID) لكل سجل (log) في Node.js عن طريق تخزين معرف لكل طلب في AsyncLocalStorage وقراءته من pino mixin. تقوم إحدى برمجيات Express الوسيطة (middleware) بإنشاء المعرف أو إعادة استخدامه، بحيث تقرأه كل سطر سجل، ومعالج أخطاء، ومكالمة خارجية من السياق (context)، وليس كمعامل ممرر (threaded parameter).
ملخص
عندما يتشعب طلب واحد عبر البرمجيات الوسيطة والخدمات ونقاط await، فأنت بحاجة إلى معرف واحد يربط أسطر السجلات الخاصة به معًا. يقوم هذا البرنامج التعليمي بربط ذلك باستخدام node:async_hooks AsyncLocalStorage1 المدمجة — بدون مكتبة ارتباط خارجية — على Express 5.2.1 مع pino 10.3.1 و TypeScript 6.0.3.2 ستقوم ببناء مخزن سياق طلب مكتوب (typed request-context store)، وبرمجية Express وسيطة تعيد استخدام معرف x-correlation-id الوارد أو تنشئ UUID جديدًا، ومسجل pino يطبع المعرف على كل سطر، ونشر خارجي للخدمات التابعة. يستغرق الأمر حوالي ستة ملفات صغيرة و20 دقيقة. تم تشغيل كل كتلة كود وكل سطر سجل أدناه على Node 22 والتحقق منها قبل النشر.
ما ستتعلمه
- لماذا يتفوق
AsyncLocalStorageعلى تمرير وسيطrequestIdعبر كل دالة - كيفية إنشاء مخزن سياق طلب مكتوب باستخدام
AsyncLocalStorage<RequestContext> - كيفية كتابة برمجية Express وسيطة تعيد استخدام أو تنشئ معرف ارتباط
- كيفية جعل pino يربط معرف الارتباط بكل سطر سجل باستخدام
mixin - كيفية قراءة المعرف في معالجات المسارات ومعالجات الأخطاء دون تمريره يدوياً
- كيفية نشر معرف الارتباط إلى الخدمات التابعة في مكالمات
fetchالخارجية - لماذا يعيد
getStore()أحياناًundefined، وكيف يقومAsyncResource.bindبإصلاح ذلك - كيفية التحقق من التدفق بالكامل باستخدام
curlومخرجات السجل الحقيقية
المتطلبات الأساسية
- Node.js 20 أو أحدث (يوصى بـ Node 22 LTS). أصبح
AsyncLocalStorageمستقراً منذ Node 16.41؛ وتستهدف pino 10 إصدار LTS الحالي. تم التحقق من كل شيء أدناه على Node 22.22.3. - pino 10.3.1، و Express 5.2.1، و TypeScript 6.0.3، و tsx 4.22.4، مثبتة لضمان إعادة إنتاج البناء الخاص بك.2
- الإلمام ببرمجيات Express الوسيطة و
async/await.
لا يلزم وجود حزمة معرف ارتباط خارجية. توجد مكتبات مثل express-correlation-id و correlation-id وتعمل، لكن الآلية التي تغلفها — AsyncLocalStorage — تأتي مدمجة مع Node، لذا سنستخدمها مباشرة ونحافظ على طبقة السياق خالية من التبعيات.
الخطوة 1 — إنشاء مشروع Node.js + Express
أنشئ مشروعاً، وحوله إلى ES modules، وقم بتثبيت الحزم المحددة:
mkdir correlation-demo && cd correlation-demo
npm init -y
npm pkg set type=module
npm install pino@10.3.1 express@5.2.1
npm install -D TypeScript@6.0.3 tsx@4.22.4 @types/node@26.0.1 @types/express@5.0.6
أضف ملف tsconfig.json. نقوم بتشغيل TypeScript مباشرة باستخدام tsx (بدون خطوة بناء) ونستخدم tsc --noEmit فقط للتحقق من الأنواع، لذا يُسمح بامتدادات استيراد .ts:
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "*.ts"]
}
الخطوة 2 — إنشاء مخزن سياق طلب مكتوب
يمنح AsyncLocalStorage كل طلب "صندوق" بيانات خاص به يتبع التدفق غير المتزامن — عبر await والوعود (promises) وردود النداء (callbacks) — حتى لا تضطر إلى سحب وسيط requestId عبر كل دالة.1 قم بتعريف المخزن مرة واحدة وتصدير مساعدين مكتوبين صغيرين:
// src/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
correlationId: string;
startedAt: number;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithContext<T>(context: RequestContext, callback: () => T): T {
return storage.run(context, callback);
}
export function getContext(): RequestContext | undefined {
return storage.getStore();
}
export function getCorrelationId(): string | undefined {
return storage.getStore()?.correlationId;
}
هناك أمران مهمان هنا. تم تحديد نوع المخزن كـ AsyncLocalStorage<RequestContext>، لذا فإن getStore() تعيد RequestContext | undefined — حالة الـ undefined حقيقية، ويجبرك TypeScript على التعامل مع حالة "لا يوجد طلب نشط". ونقوم بتغليف storage.run، وليس storage.enterWith: توصي وثائق Node.js باستخدام run() لأن enterWith() لا يخرج تلقائياً — حيث يستمر سياقه خلال العمل المتزامن وغير المتزامن اللاحق بدلاً من أن يكون مقيداً برد نداء (callback).3
الخطوة 3 — كتابة برمجية معرف الارتباط الوسيطة
البرمجية الوسيطة هي المكان الذي يدخل فيه الطلب إلى المخزن. تعيد استخدام ترويسة x-correlation-id الواردة إذا أرسلها المتصل (بحيث يظل المعرف المخصص من قبل بوابتك أو خدمة سابقة حياً)، وإلا فإنها تنشئ UUID v4 جديداً باستخدام crypto.randomUUID() المدمج.4 تقوم بإرجاع المعرف في الاستجابة ثم تشغل بقية الطلب داخل المخزن:
// src/correlation.ts
import type { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
import { runWithContext } from './context.ts';
const HEADER = 'x-correlation-id';
export function correlationId(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(HEADER);
const id = incoming && incoming.trim() !== '' ? incoming : randomUUID();
res.setHeader(HEADER, id);
runWithContext({ correlationId: id, startedAt: Date.now() }, next);
}
تمرير next كـ رد نداء لـ run هو السر بالكامل: نظرًا لأن Express يستدعي بقية سلسلة البرمجيات الوسيطة ومعالج المسار الخاص بك من داخل هذا الاستدعاء، فإنهم جميعًا ينفذون داخل المخزن — بما في ذلك كل ما يحدث بعد await.
الخطوة 4 — جعل pino يربط المعرف بكل سطر سجل
يمكنك الحصول على معرف الارتباط في كل سطر سجل باستخدام خيار mixin في pino: وهي دالة يستدعيها pino في كل سجل، ويدمج قيمتها المرجعة في السجل.5 اقرأ المخزن بداخلها، وإذا كان هناك طلب نشط، أضف المعرف:
// src/logger.ts
import pino from 'pino';
import { getContext } from './context.ts';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
mixin() {
const context = getContext();
return context ? { correlationId: context.correlationId } : {};
},
});
نظرًا لأن المخزن يتم ملؤه في البرمجية الوسيطة في الخطوة 3، فإن أي logger.info(...) داخل الطلب يلتقط المعرف بدون أي وسائط إضافية. إذا قمت بالتسجيل خارج الطلب — عند بدء التشغيل مثلاً — فإن الـ mixin يعيد {}، لذا لا يوجد حقل correlationId زائد. لن تضطر أبدًا لتمرير معرف الطلب إلى المسجل يدويًا.
الخطوة 5 — ربط الخادم وقراءة المعرف في أي مكان
قم بتسجيل البرمجية الوسيطة قبل مساراتك، ثم اقرأ المعرف في أي مكان لاحقاً باستخدام getCorrelationId() — لا يحتاج أي معالج إليه كمعامل:
// src/server.ts
import express from 'express';
import type { Request, Response, NextFunction } from 'express';
import { correlationId } from './correlation.ts';
import { logger } from './logger.ts';
import { getCorrelationId } from './context.ts';
const app = express();
app.use(correlationId);
app.get('/work', async (_req: Request, res: Response) => {
logger.info('handling /work');
await new Promise((resolve) => setTimeout(resolve, 20));
logger.info('done with async work');
res.json({ ok: true, correlationId: getCorrelationId() });
});
app.get('/boom', async (_req: Request, _res: Response) => {
await new Promise((resolve) => setTimeout(resolve, 5));
throw new Error('downstream exploded');
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
logger.error({ err: err.message }, 'request failed');
res.status(500).json({ error: 'internal', correlationId: getCorrelationId() });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => logger.info({ port }, 'listening'));
قم بتشغيله باستخدام npx tsx src/server.ts. يسجل مسار /work مرتين — مرة قبل تأخير 20 مللي ثانية ومرة بعده — ويحمل كلا السطرين نفس المعرف، مما يثبت أن المخزن ينجو من الـ await.
الخطوة 6 — التقاط الأخطاء مع بقاء السياق سليماً
معالجات الأخطاء هي بالضبط المكان الذي يثبت فيه معرف الارتباط جدارته، وهذا مكان يساعد فيه Express 5. في Express 5، يتم توجيه الوعد المرفوض (rejected promise) الذي يتم إلقاؤه من معالج مسار async إلى برمجية معالجة الأخطاء الوسيطة تلقائيًا — بدون try/catch وبدون شريحة express-async-errors.6 نظرًا لأن الرفض ينتشر في الاستمرارية غير المتزامنة التي بدأت داخل run()، فإن معالج الأخطاء لا يزال داخل المخزن، لذا يتم حل كل من getCorrelationId() و pino mixin. لهذا السبب يقوم معالج /boom أعلاه بإلقاء الخطأ بحرية ويمكن لكتلة app.use((err, …)) النهائية تسجيل وإرجاع نفس المعرف.
الخطوة 7 — تمرير المعرف إلى الخدمات التابعة
معرف الارتباط (correlation ID) يكون مفيداً عبر النظام فقط إذا انتقل بين أجزائه. قم بتمريره في المكالمات الصادرة عن طريق قراءة المخزن وضبط الترويسة (header) في طلب fetch الخاص بك:
// src/downstream.ts
import { getCorrelationId } from './context.ts';
export async function callDownstream(url: string): Promise<Response> {
const id = getCorrelationId();
const headers: Record<string, string> = {};
if (id) headers['x-correlation-id'] = id;
return fetch(url, { headers });
}
استدعِ callDownstream(url) من داخل أي معالج طلبات، وستتلقى الخدمة المستلمة نفس الـ x-correlation-id. إذا كانت تلك الخدمة تشغل البرمجية الوسيطة (middleware) من الخطوة 3، فستعيد استخدام المعرف بدلاً من إنشاء واحد جديد، وبذلك يغطي معرف واحد سجلات الخدمتين معاً. لقد تحققت من ذلك باستخدام مسار /proxy يستدعي خدمة /echo محلية: مع وجود معرف وارد trace-abc، كانت الاستجابة {"here":"trace-abc","downstreamReceived":"trace-abc"}.
لماذا تعيد getStore() القيمة undefined: فخ EventEmitter
خطأ شائع في AsyncLocalStorage هو أن تعيد getStore() القيمة undefined في مكان كنت تتوقع فيه وجود السياق (context). السبب دائماً ما يكون الهروب من السلسلة غير المتزامنة (async chain) — وغالباً ما يكون ذلك بسبب EventEmitter تم تسجيل المستمع (listener) الخاص به أثناء الطلب ولكنه يعمل لاحقاً، خارجه. تعمل المستمعات مع السياق النشط في وقت الإطلاق (emit)، وليس وقت التسجيل، لذا فإن الإطلاق المنفصل لا يرى المخزن.
الحل هو AsyncResource.bind، الذي يلتقط السياق الحالي ويعيد الدخول إليه عند تشغيل الوظيفة المغلفة.7 إليك الفخ والحل جنباً إلى جنب:
// src/pitfalls.ts
import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
import { EventEmitter } from 'node:events';
const als = new AsyncLocalStorage<{ id: string }>();
const bus = new EventEmitter();
let fireUnbound: () => void = () => {};
let fireBound: () => void = () => {};
als.run({ id: 'req-2' }, () => {
bus.on('unbound', () => {
console.log('UNBOUND listener store =', als.getStore()?.id);
});
bus.on('bound', AsyncResource.bind(() => {
console.log('BOUND listener store =', als.getStore()?.id);
}));
fireUnbound = () => bus.emit('unbound');
fireBound = () => bus.emit('bound');
});
// Emit later, outside any als.run() context (e.g. from a timer):
setTimeout(() => {
fireUnbound();
fireBound();
}, 30);
تشغيل هذا الكود يطبع فقدان المستمع غير المرتبط (unbound) للسياق، واحتفاظ المستمع المرتبط (bound) به:
UNBOUND listener store = undefined
BOUND listener store = req-2
الاستخدام العادي لـ await و setTimeout لا يفقد السياق — فهما جزء من السلسلة غير المتزامنة التي يتتبعها AsyncLocalStorage، ولهذا السبب نجحت الخطوة 5 دون الحاجة لأي ربط (binding). فقط عندما تخرج عن تلك السلسلة (مثل باعثات الأحداث، أو ردود النداء لاتصال مشترك، أو بعض واجهات برمجة تطبيقات رد النداء من جهات خارجية) ستحتاج إلى AsyncResource.bind. يذهب Node 24 إلى أبعد من ذلك ويستبدل التنفيذ الداخلي بـ AsyncContextFrame افتراضياً لتحسين الأداء والمتانة، لكن الكود أعلاه يعمل بنفس الطريقة على Node 20 و 22 و 24.8
التحقق
ابدأ تشغيل الخادم، ثم اختبره باستخدام curl. أولاً، دع الخادم ينشئ معرفاً:
npx tsx src/server.ts # in one terminal
curl -si http://localhost:3000/work | grep -i x-correlation-id
سترى الاستجابة تحمل معرفاً تم إنشاؤه، على سبيل المثال:
x-correlation-id: 9b1e5d02-6b1a-4a1f-8b3a-2c0d5f7e9a11
الآن أرسل معرفك الخاص وشاهده وهو يُعاد استخدامه بدلاً من استبداله:
curl -s -H 'x-correlation-id: abc-123' http://localhost:3000/work
# {"ok":true,"correlationId":"abc-123"}
في نافذة الخادم، سيشترك كلا سطري السجل لهذا الطلب في المعرف. يكتب pino بتنسيق JSON مفصول بأسطر جديدة؛ الـ level 30 هو info، والـ time هو مللي ثانية من تاريخ البداية (epoch)، وستختلف قيم pid/hostname/time على جهازك:
{"level":30,"time":1782897300000,"pid":4821,"hostname":"API-01","correlationId":"abc-123","msg":"handling /work"}
{"level":30,"time":1782897300021,"pid":4821,"hostname":"API-01","correlationId":"abc-123","msg":"done with async work"}
أخيراً، تأكد من أن مسار الخطأ يحتفظ بالمعرف:
curl -s -H 'x-correlation-id: boom-777' http://localhost:3000/boom
# {"error":"internal","correlationId":"boom-777"}
سجل الخادم المطابق هو سطر error (الـ level 50) يحمل أيضاً boom-777 — لقد نجا المعرف من عملية الرمي (throw) إلى معالج الأخطاء.
استكشاف الأخطاء وإصلاحها
getStore() تعيد undefined داخل مسار. يتم ضبط المخزن فقط للكود الذي يعمل بعد البرمجية الوسيطة correlationId. تأكد من أن app.use(correlationId) يأتي قبل المسارات التي تقرأه، وأنه لا يوجد شيء يستدعي next() خارج رد نداء run.
المعرف مفقود فقط داخل حدث/رد نداء. لقد خرجت من السلسلة غير المتزامنة — انظر الفخ المذكور أعلاه. قم بتغليف المستمع أو رد النداء بـ AsyncResource.bind(fn) عند التسجيل حتى يلتقط السياق الحالي.
السجلات لا تظهر أي correlationId على الإطلاق. تأكد من أن المسجل يستخدم الـ mixin من الخطوة 4 ويستورد getContext من نفس وحدة context.ts. لا يتشارك مثيلان مختلفان من AsyncLocalStorage (على سبيل المثال، من وحدة مكررة) في المخزن، لذا يقرأ الـ mixin مخزناً فارغاً.
خطأ error TS5097 عند import './context.ts'. الرسالة — "لا يمكن لمسار الاستيراد أن ينتهي بامتداد '.ts' إلا عند تمكين 'allowImportingTsExtensions'" — تعني أن خيار المترجم هذا معطل. الخطوة 1 في tsconfig.json تقوم بتفعيله، ويحتاج TypeScript حينها أيضاً إلى ضبط أحد الخيارات: noEmit، أو emitDeclarationOnly، أو rewriteRelativeImportExtensions (تستخدم الخطوة 1 noEmit، لأننا نشغل الكود باستخدام tsx ولا نقوم بالإصدار أبداً). لبناء tsc للإنتاج يصدر JavaScript، قم بتمكين rewriteRelativeImportExtensions أو استخدم محددات استيراد .js بدلاً من ذلك.
مهمة خلفية أو setInterval تم إنشاؤها عند بدء التشغيل لا تملك سياقاً. لم تدخل أبداً في run() الخاص بالطلب. إذا كانت المهمة المجدولة تحتاج إلى معرف، فابدأ سياقها الخاص باستخدام runWithContext({ correlationId: randomUUID(), startedAt: Date.now() }, task).
الخطوات التالية ومزيد من القراءة
- أضف معرف الارتباط إلى مقاييسك (metrics) وتتبعاتك (traces) حتى تتماشى السجلات والمقاييس والنطاقات (spans). انظر مقاييس Prometheus المخصصة في Node.js و Express لمعرفة جانب المقاييس.
- اربط هذا مع قواطع الدائرة (circuit breakers) في Node.js باستخدام opossum بحيث يكون التبعية الفاشلة معزولة وقابلة للتتبع بالمعرف.
- قم بتمرير المعرف عبر استدعاءات الخدمة في إعداد RPC مكتوب (typed) باستخدام الأنماط الموجودة في دليل إنتاج gRPC في Node.js و TypeScript.
- اقرأ وثائق تتبع السياق غير المتزامن الرسمية لـ Node.js للتعرف على
snapshot()، وwithScope()، وخيارات منشئ Node 24.
بمجرد أن يتدفق المعرف عبر سجلاتك، يصبح تصفية حادث ما وصولاً إلى طلب واحد عبارة عن استعلام واحد بدلاً من لعبة تخمين.
Footnotes
-
وثائق Node.js، "تتبع السياق غير المتزامن" —
AsyncLocalStorageهو جزء منnode:async_hooks؛ تمت إضافته (تجريبي) في v13.10.0 / v12.17.0 وتم تمييزه كمستقر (Stability 2) في v16.4.0. https://nodejs.org/API/async_context.html ↩ ↩2 ↩3 -
تم التحقق من الإصدارات مقابل سجل npm في 2026-07-01:
pino10.3.1،express5.2.1 (محركاتnode >= 18)،TypeScript6.0.3،tsx4.22.4،@types/node26.0.1،@types/express5.0.6. https://www.npmjs.com/package/pino ↩ ↩2 -
توثيق Node.js،
asyncLocalStorage.enterWith()/run()— "يُفضل استخدامrun()علىenterWith()" لأن السياق الذي يتم الدخول إليه باستخدامenterWith()لا يخرج تلقائيًا. https://nodejs.org/API/async_context.html#asynclocalstorageenterwithstore ↩ -
توثيق Node.js،
crypto.randomUUID([options])يعيد UUID عشوائي من الإصدار 4 لـ RFC 4122. https://nodejs.org/API/crypto.html#cryptorandomuuidoptions ↩ -
توثيق pino، خيار
mixin— "يتم استدعاؤه في كل مرة يتم فيها استدعاء إحدى طرق تسجيل السجلات (logging) النشطة،" و "سيتم إضافة خصائص الكائن المُعاد إلى JSON المسجل." https://GitHub.com/pinojs/pino/blob/main/docs/API.md ↩ -
دليل الانتقال إلى Express 5، "الوعود المرفوضة (Rejected promises) التي يتم التعامل معها من البرمجيات الوسيطة (middleware) والمعالجات" — "المعالجات التي تعيد وعودًا مرفوضة يتم التعامل معها الآن عن طريق توجيه القيمة المرفوضة كـ Error إلى البرمجية الوسيطة لمعالجة الأخطاء." تم التحقق من ذلك تجريبيًا على Express 5.2.1. https://expressjs.com/en/guide/migrating-5.html ↩
-
توثيق Node.js، "دمج
AsyncResourceمعEventEmitter" وAsyncResource.bind(fn)— يربط دالة بسياق التنفيذ النشط حاليًا. https://nodejs.org/API/async_context.html#integrating-asyncresource-with-eventemitter ↩ -
ملاحظات إصدار Node.js v24.0.0 —
AsyncLocalStorageيستخدم الآنAsyncContextFrameكإعداد افتراضي له. https://nodejs.org/en/blog/release/v24.0.0 ↩