---
title: Correlation IDs في Node.js باستخدام
description: إضافة correlation ID لكل سطر log في Node.js باستخدام AsyncLocalStorage المدمجة و Express — typed، و zero-dependency، بالإضافة إلى pino و downstream propagation.
url: https://nerdleveltech.com/ar/nodejs-correlation-id-asynclocalstorage-tutorial
language: ar
date: '2026-07-01'
category: backend
tags:
  - asynclocalstorage
  - correlation-id
  - node.js
  - express
  - typescript
  - pino
  - observability
  - backend
markdown_url: https://nerdleveltech.com/ar/nodejs-correlation-id-asynclocalstorage-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/nodejs-correlation-id-asynclocalstorage-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Correlation IDs في Node.js باستخدام

يمكنك إضافة معرف ارتباط (correlation ID) لكل سجل (log) في Node.js عن طريق تخزين معرف لكل طلب في `AsyncLocalStorage` وقراءته من pino `mixin`. تقوم إحدى برمجيات Express الوسيطة (middleware) بإنشاء المعرف أو إعادة استخدامه، بحيث تقرأه كل سطر سجل، ومعالج أخطاء، ومكالمة خارجية من السياق (context)، وليس كمعامل ممرر (threaded parameter).

## ملخص

عندما يتشعب طلب واحد عبر البرمجيات الوسيطة والخدمات ونقاط `await`، فأنت بحاجة إلى معرف واحد يربط أسطر السجلات الخاصة به معًا. يقوم هذا البرنامج التعليمي بربط ذلك باستخدام `node:async_hooks` `AsyncLocalStorage`[^als] **المدمجة** — بدون مكتبة ارتباط خارجية — على **Express 5.2.1** مع **pino 10.3.1** و **TypeScript 6.0.3**.[^npm] ستقوم ببناء مخزن سياق طلب مكتوب (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.4[^als]؛ وتستهدف pino 10 إصدار LTS الحالي. تم التحقق من كل شيء أدناه على Node 22.22.3.
- **pino 10.3.1**، و **Express 5.2.1**، و **TypeScript 6.0.3**، و **tsx 4.22.4**، مثبتة لضمان إعادة إنتاج البناء الخاص بك.[^npm]
- الإلمام ببرمجيات Express الوسيطة و `async/await`.

لا يلزم وجود حزمة معرف ارتباط خارجية. توجد مكتبات مثل `express-correlation-id` و `correlation-id` وتعمل، لكن الآلية التي تغلفها — `AsyncLocalStorage` — تأتي مدمجة مع Node، لذا سنستخدمها مباشرة ونحافظ على طبقة السياق خالية من التبعيات.

## الخطوة 1 — إنشاء مشروع Node.js + Express

أنشئ مشروعاً، وحوله إلى ES modules، وقم بتثبيت الحزم المحددة:

```bash
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`:

```json
{
  "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` عبر كل دالة.[^als] قم بتعريف المخزن مرة واحدة وتصدير مساعدين مكتوبين صغيرين:

```TypeScript
// 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).[^enterwith]

## الخطوة 3 — كتابة برمجية معرف الارتباط الوسيطة

البرمجية الوسيطة هي المكان الذي يدخل فيه الطلب إلى المخزن. تعيد استخدام ترويسة `x-correlation-id` الواردة إذا أرسلها المتصل (بحيث يظل المعرف المخصص من قبل بوابتك أو خدمة سابقة حياً)، وإلا فإنها تنشئ UUID v4 جديداً باستخدام `crypto.randomUUID()` المدمج.[^uuid] تقوم بإرجاع المعرف في الاستجابة ثم تشغل بقية الطلب داخل المخزن:

```TypeScript
// 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 في كل سجل، ويدمج قيمتها المرجعة في السجل.[^pino] اقرأ المخزن بداخلها، وإذا كان هناك طلب نشط، أضف المعرف:

```TypeScript
// 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()` — لا يحتاج أي معالج إليه كمعامل:

```TypeScript
// 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`.[^express5] نظرًا لأن الرفض ينتشر في الاستمرارية غير المتزامنة التي بدأت داخل `run()`، فإن معالج الأخطاء لا يزال داخل المخزن، لذا يتم حل كل من `getCorrelationId()` و pino `mixin`. لهذا السبب يقوم معالج `/boom` أعلاه بإلقاء الخطأ بحرية ويمكن لكتلة `app.use((err, …))` النهائية تسجيل وإرجاع نفس المعرف.

## الخطوة 7 — تمرير المعرف إلى الخدمات التابعة

معرف الارتباط (correlation ID) يكون مفيداً عبر النظام فقط إذا انتقل بين أجزائه. قم بتمريره في المكالمات الصادرة عن طريق قراءة المخزن وضبط الترويسة (header) في طلب `fetch` الخاص بك:

```TypeScript
// 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`، الذي يلتقط السياق الحالي ويعيد الدخول إليه عند تشغيل الوظيفة المغلفة.[^asyncresource] إليك الفخ والحل جنباً إلى جنب:

```TypeScript
// 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) به:

```text
UNBOUND listener store = undefined
BOUND listener store   = req-2
```

الاستخدام العادي لـ `await` و `setTimeout` **لا** يفقد السياق — فهما جزء من السلسلة غير المتزامنة التي يتتبعها `AsyncLocalStorage`، ولهذا السبب نجحت الخطوة 5 دون الحاجة لأي ربط (binding). فقط عندما تخرج عن تلك السلسلة (مثل باعثات الأحداث، أو ردود النداء لاتصال مشترك، أو بعض واجهات برمجة تطبيقات رد النداء من جهات خارجية) ستحتاج إلى `AsyncResource.bind`. يذهب Node 24 إلى أبعد من ذلك ويستبدل التنفيذ الداخلي بـ `AsyncContextFrame` افتراضياً لتحسين الأداء والمتانة، لكن الكود أعلاه يعمل بنفس الطريقة على Node 20 و 22 و 24.[^node24]

## التحقق

ابدأ تشغيل الخادم، ثم اختبره باستخدام `curl`. أولاً، دع الخادم ينشئ معرفاً:

```bash
npx tsx src/server.ts   # in one terminal
curl -si http://localhost:3000/work | grep -i x-correlation-id
```

سترى الاستجابة تحمل معرفاً تم إنشاؤه، على سبيل المثال:

```text
x-correlation-id: 9b1e5d02-6b1a-4a1f-8b3a-2c0d5f7e9a11
```

الآن أرسل معرفك الخاص وشاهده وهو يُعاد استخدامه بدلاً من استبداله:

```bash
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` على جهازك:

```text
{"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"}
```

أخيراً، تأكد من أن مسار الخطأ يحتفظ بالمعرف:

```bash
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](https://nerdleveltech.com/prometheus-metrics-nodejs-express-prom-client-tutorial) لمعرفة جانب المقاييس.
- اربط هذا مع [قواطع الدائرة (circuit breakers) في Node.js باستخدام opossum](https://nerdleveltech.com/nodejs-circuit-breaker-opossum-TypeScript-tutorial) بحيث يكون التبعية الفاشلة معزولة وقابلة للتتبع بالمعرف.
- قم بتمرير المعرف عبر استدعاءات الخدمة في إعداد RPC مكتوب (typed) باستخدام الأنماط الموجودة في [دليل إنتاج gRPC في Node.js و TypeScript](https://nerdleveltech.com/grpc-nodejs-TypeScript-production-guide).
- اقرأ [وثائق تتبع السياق غير المتزامن الرسمية لـ Node.js](https://nodejs.org/API/async_context.html) للتعرف على `snapshot()`، و `withScope()`، وخيارات منشئ Node 24.

بمجرد أن يتدفق المعرف عبر سجلاتك، يصبح تصفية حادث ما وصولاً إلى طلب واحد عبارة عن استعلام واحد بدلاً من لعبة تخمين.

[^als]: وثائق Node.js، "تتبع السياق غير المتزامن" — `AsyncLocalStorage` هو جزء من `node:async_hooks`؛ تمت إضافته (تجريبي) في v13.10.0 / v12.17.0 وتم تمييزه كمستقر (Stability 2) في v16.4.0. <https://nodejs.org/API/async_context.html>

[^npm]: تم التحقق من الإصدارات مقابل سجل npm في 2026-07-01: `pino` 10.3.1، `express` 5.2.1 (محركات `node >= 18`)، `TypeScript` 6.0.3، `tsx` 4.22.4، `@types/node` 26.0.1، `@types/express` 5.0.6. <https://www.npmjs.com/package/pino>

[^enterwith]: توثيق Node.js، `asyncLocalStorage.enterWith()` / `run()` — "يُفضل استخدام `run()` على `enterWith()`" لأن السياق الذي يتم الدخول إليه باستخدام `enterWith()` لا يخرج تلقائيًا. <https://nodejs.org/API/async_context.html#asynclocalstorageenterwithstore>

[^uuid]: توثيق Node.js، `crypto.randomUUID([options])` يعيد UUID عشوائي من الإصدار 4 لـ RFC 4122. <https://nodejs.org/API/crypto.html#cryptorandomuuidoptions>

[^pino]: توثيق pino، خيار `mixin` — "يتم استدعاؤه في كل مرة يتم فيها استدعاء إحدى طرق تسجيل السجلات (logging) النشطة،" و "سيتم إضافة خصائص الكائن المُعاد إلى JSON المسجل." <https://GitHub.com/pinojs/pino/blob/main/docs/API.md>

[^express5]: دليل الانتقال إلى Express 5، "الوعود المرفوضة (Rejected promises) التي يتم التعامل معها من البرمجيات الوسيطة (middleware) والمعالجات" — "المعالجات التي تعيد وعودًا مرفوضة يتم التعامل معها الآن عن طريق توجيه القيمة المرفوضة كـ Error إلى البرمجية الوسيطة لمعالجة الأخطاء." تم التحقق من ذلك تجريبيًا على Express 5.2.1. <https://expressjs.com/en/guide/migrating-5.html>

[^asyncresource]: توثيق Node.js، "دمج `AsyncResource` مع `EventEmitter`" و `AsyncResource.bind(fn)` — يربط دالة بسياق التنفيذ النشط حاليًا. <https://nodejs.org/API/async_context.html#integrating-asyncresource-with-eventemitter>

[^node24]: ملاحظات إصدار Node.js v24.0.0 — `AsyncLocalStorage` يستخدم الآن `AsyncContextFrame` كإعداد افتراضي له. <https://nodejs.org/en/blog/release/v24.0.0>
