ترويسة traceparent: دليل W3C Trace Context الكامل
ترويسة traceparent هي ترويسة التتبع الموزّع المعيارية: سطر واحد من محارف ASCII يحمل هوية الطلب عبر كل خدمة يمرّ بها. طولها في الإصدار الحالي 55 محرفًا بالضبط، وتتألف من أربعة حقول تفصل بينها شُرَط:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ trace-flags (2 hex, 1 byte)
│ │ └─ parent-id (16 hex, 8 bytes)
│ └─ trace-id (32 hex, 16 bytes)
└─ version (2 hex, 1 byte)
حقلان من هذه الحقول يسلكان سلوكًا مختلفًا أثناء تنقّل الطلب. فـ الـ trace-id يبقى كما هو عند كل قفزة؛ إنه اسم الطلب، من الوكيل الطرفي حتى آخر استدعاء لقاعدة البيانات. أما الـ parent-id فيتبدّل عند كل قفزة، لأنه يسمّي الـ span (وحدة العمل المُتتبَّعة) الذي استدعاك، لا الطلب. والخلط بين الاثنين يقف خلف نصيب وافر من بلاغات «عمليات التتبع عندي تبدو خاطئة».
هذا هو التشريح. والأصعب هو ما لا يستطيع جدول الحقول أن يخبرك به: ما الذي يجعل الترويسة غير صالحة، وماذا يفعل المستقبِل المطابق للمواصفة حين تصله واحدة كذلك، وأين تختفي الترويسة بهدوء بين خدمتين تعلن كلتاهما دعم التتبع. وإن كانت أمامك ترويسة حقيقية، فالصقها في محلّل traceparent المجاني وتابع القراءة؛ فهو يفكّ بايت الرايات بتًا بتًا ويسمّي القاعدة التي تعثّرت عندها الترويسة المعطوبة.
ترويسة traceparent بلمحة
ترويسة traceparent تنقل تتبّعًا موزّعًا بين الخدمات داخل ترويسة HTTP واحدة. تضمّ أربعة حقول ست عشرية تفصل بينها شُرَط: version وtrace-id وparent-id وtrace-flags، ويبلغ طولها تحت الإصدار الحالي 55 محرفًا تمامًا. الـ trace-id يسمّي الطلب كله، والـ parent-id يسمّي الـ span الذي استدعاك.
| الحقل | خانات ست عشرية | بايت | ما الذي يُعرّفه | يتبدّل مع كل قفزة؟ |
|---|---|---|---|---|
version | 2 | 1 | الصيغة التي يتبعها ما بعده. وهو 00 دائمًا اليوم | لا |
trace-id | 32 | 16 | الطلب كله، من طرف إلى طرف | لا |
parent-id | 16 | 8 | الـ span المُستدعي (معرّف الـ span لمن استدعاك) | نعم |
trace-flags | 2 | 1 | حقل من 8 بتات؛ البت 0 هو sampled | نادرًا |
أضف الشُّرَط الثلاث إلى تلك الخانات الست عشرية الاثنتين والخمسين فتحصل على 55 محرفًا. ويستحق هذا الرقم أن يُحفَظ، لأن أي ترويسة من الإصدار 00 بطول آخر غير صالحة، والطول أسرع ما يمكن فحصه بالنظر المجرّد.
كل ما في الترويسة ست عشري بأحرف صغيرة. لا «ست عشري غير حسّاس لحالة الأحرف»، بل أحرف صغيرة. فالصياغة في توصية W3C Trace Context لا تقبل سوى 0-9 وa-f ولا شيء غيرهما، ولهذا فإن معرّف تتبع مكتوبًا بأحرف كبيرة، مهما كانت قيمته سليمة تمامًا، يُرمى في الخدمة التالية.
حقلًا بحقل
لكل حقل عرض ثابت، وقيم يُعدّ عندها غير صالح، وطريقة خاصة به في التعطّل.
version — لماذا لا يكون دائمًا «مجرّد 00»
اليوم بايت الإصدار هو 00، وسيظل 00 مدةً من الزمن. لكن ff ممنوع صراحةً: تحجزه المواصفة قيمةً غير صالحة، فالترويسة التي تفتتح بـ ff ميتة عند الوصول مهما جاء بعدها.
القاعدة اللافتة هي تلك المتعلقة بالإصدارات التي لم ترها قط. فمحلّل يكتب if (version !== '00') reject() مخطئ، ومخطئ على نحو مكلف. تطلب المواصفة من المستقبِل أن يحاول التحليل حين يكون الإصدار أعلى وطول الترويسة لا يقل عن طول الصيغة المعروفة: اقرأ الحقول التي تعرفها، واحتمل أي بيانات زائدة في الذيل، وامضِ. أما الرفض فيجعل خدمتك الحدّ الذي يتوقف عنده التتبع ويبدأ عنده تتبّع جديد، لحظةَ يُرقّي أحدٌ أعلى منك في السلسلة.
// Wrong: makes your service the place traces go to die
if (version !== '00') throw new Error('bad traceparent');
// Right: parse the prefix you understand
if (version !== '00' && header.length >= 55) {
// read version, trace-id, parent-id, trace-flags; ignore the rest
}
trace-id — 16 بايت، هوية الطلب بأكمله
اثنتان وثلاثون خانة ست عشرية بأحرف صغيرة، ثابتة طوال حياة التتبع. وأيًّا كانت الخدمة التي ولّدتها في البداية، تنسخها كل قفزة إلى ما بعدها بلا تغيير. وحين تبحث في نظام الرصد لديك عن تتبّع، فهذه هي السلسلة التي تلصقها.
قاعدتان تحكمان قيمتها. يجب أن تكون 32 خانة ست عشرية، ويجب ألّا تكون أصفارًا كلها. فـ 00000000000000000000000000000000 ليست «تتبّعًا بلا بيانات بعد»، بل تسمّيها المواصفة قيمةً غير صالحة وتُلزم المستقبِل بتجاهل الترويسة كاملةً. وعمليًّا يعني معرّف التتبع الصِّفري إما حزمة SDK لم تُهيّأ قط، وإما وسيطًا برمجيًّا يحشر قيمة نائبة لأنه لم يجد سياقًا حقيقيًّا يمرّره.
الـ trace-id بعرض 128 بت، وهو عرض الـ UUID نفسه، ومع ذلك ليس UUID. لا بتات إصدار، ولا بتات متغيّر، ولا شُرَط، ولا بنية من أي نوع: ستة عشر بايتًا معتمة. لا يمكنك أن تستخرج منه إصدارًا رابعًا، كما أن UUID مجرّدًا من شُرَطه لا يصير تلقائيًّا trace-id صالحًا، لأن نبلات الإصدار والمتغيّر تجعل عشوائيته غير منتظمة. وإن أردت أن ترى ما يحجزه الـ UUID فعلًا داخل تلك الـ 128 بت، فمقال ما هو UUID؟ دليل التنسيق والإصدارات وحالات الاستخدام يشرح التخطيط، ومولّد UUID يُظهر بتات الإصدار والمتغيّر في مواضعها.
parent-id — 8 بايتات، الـ span الذي استدعاك
ستّ عشرة خانة ست عشرية، تُعاد كتابتها عند كل قفزة. والاسم يثير من الالتباس أكثر مما يستحقه الحقل: مواصفة W3C تسمّيه parent-id، وOpenTelemetry يسمّي البايتات الثمانية نفسها معرّف span، وهما الشيء ذاته منظورًا إليه من جهتين. فمن زاوية خدمتك هو الأب؛ ومن زاوية المستدعي هو معرّف الـ span الذي أنشأه للتوّ للطلب الصادر.
فحين تستدعي الخدمة A الخدمة B، تضع A معرّف الـ span الخاص بها في خانة الـ parent-id. ثم تنشئ B ابنًا لذلك الـ span، وحين تستدعي B الخدمة C تضع معرّف الـ span الخاص بـ B مكانه. ويبقى الـ trace-id بلا مساس طوال ذلك. هذه هي خوارزمية النشر كلها.
ومعرّفات الأب الصِّفرية غير صالحة أيضًا، للسبب نفسه الذي يُبطل معرّفات التتبع الصِّفرية: 0000000000000000 يعني أن المستدعي لم يقدّم span حقيقيًّا، والأولى إسقاط الترويسة لا احترامها نصف احترام.
trace-flags — يبدو قيمة منطقية، وهو في الحقيقة ثمانية بتات
تكاد كل ترويسة ستراها في حياتك تنتهي بـ 01، فمن الطبيعي أن تقرأ الحقل قراءة «نعم/لا». لكنه بايت، وبتاته موزّعة هكذا:
- البت 0، بقناع
0x01—sampled - البت 1، بقناع
0x02—random-trace-id، أُضيف في Trace Context Level 2 - البتات 2–7 — محجوزة؛ تجاهلها عند الاستقبال، وصفّرها في الطلبات الصادرة
وهذا ما تُفَكّ إليه التركيبات:
| ست عشري | ثنائي | sampled | random-trace-id | هل يصحّ flags === 0x01؟ |
|---|---|---|---|---|
00 | 00000000 | false | false | false |
01 | 00000001 | true | false | true |
02 | 00000010 | false | true | false |
03 | 00000011 | true | true | false ← موضع الخلل |
اقرأ الصف الأخير مرة أخرى. تتبّعٌ رايته 03 هو تتبّع مأخوذ بالعيّنة. وأي شيفرة تقارن البايت كله بـ 01 تُبلّغ عنه بأنه خارج العيّنة، في صمت، ولمجموعة جزئية فقط من حركة المرور تصادف أن راية المستوى الثاني مضبوطة فيها. وهذا أسوأ أشكال العطل، لأنه يبدو مشكلةً في معدّل أخذ العيّنات لا خطأً في التحليل.
const flags = parseInt(traceFlags, 16);
// Wrong: treats a bit field as an enumeration
const sampled = traceFlags === '01';
// Right
const sampled = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;
ماذا يؤكّد random-trace-id فعلًا؟ يؤكّد أن البايتات السبعة الأقصى يمينًا من الـ trace-id على الأقل وُلّدت بعشوائية منتظمة. ويبدو هذا كلامًا نظريًّا حتى تفكّر في أخذ العيّنات المتّسق: فإذا أراد نظام في المصبّ أن يحتفظ بـ 1% من عمليات التتبع واحتاج أن تتفق كل خدمة استقلالًا على أي 1% هي، أمكنه أن يأخذ تلك البايتات بباقي القسمة على عدد ما بدل تجزئة المعرّف أولًا. والراية هي وعد الطرف الأعلى بأن هذا آمن.
ما الذي يجعل الـ traceparent غير صالحة
تعدّد المواصفة أسباب الرفض في مواضع متفرقة منها. وهذه القائمة الكاملة لترويسة من الإصدار 00:
| العَرَض | القاعدة | النتيجة |
|---|---|---|
00-4BF92F35...-01 | الصياغة لا تقبل سوى الست عشري بأحرف صغيرة | غير صالحة — القيمة سليمة والترويسة مرفوضة |
ff-... | الإصدار ff تمنعه المواصفة | غير صالحة |
الـ trace-id هو 00000000000000000000000000000000 | معرّف التتبع الصِّفري قيمة غير صالحة مُسمّاة | غير صالحة |
الـ parent-id هو 0000000000000000 | معرّف الأب الصِّفري قيمة غير صالحة مُسمّاة | غير صالحة |
| الـ trace-id ليس 32 خانة ست عشرية | عرض ثابت | غير صالحة |
| الـ parent-id ليس 16 خانة ست عشرية | عرض ثابت | غير صالحة |
| الـ trace-flags ليس خانتين ست عشريتين | عرض ثابت | غير صالحة |
الترويسة ليست 55 محرفًا بالضبط، والإصدار 00 | البيانات الزائدة في الذيل لا تجوز إلا تحت إصدار مستقبلي | غير صالحة |
أي محرف خارج 0-9a-f والشُّرَط | ليس ست عشريًّا | غير صالحة |
والنتيجة العملية:
المستقبِل المطابق للمواصفة لا يُصلح ترويسة traceparent غير صالحة ولا يمرّرها. بل يُسقطها ويبدأ تتبّعًا جديدًا تمامًا بمعرّف تتبع مولَّد للتوّ.
ومعنى ذلك أن ما تراه على شاشتك ليس تتبّعًا مكسورًا، بل تتبّعين قصيرين منفصلين: أحدهما ينتهي فجأة عند الخدمة التي أصدرت الترويسة الفاسدة، والآخر يبدو أنه يبدأ من العدم عند الخدمة التي استقبلتها. ولا شيء يُوسَم خطأً في أي مكان. وكلا التتبّعين يبدو سليمًا بمفرده. ويُمضي الناس ساعات في البحث عن الحلقة المفقودة بينهما، والجواب أن وسيطًا برمجيًّا رفع سلسلة ست عشرية إلى الأحرف الكبيرة، أو أن ترويسة بُنيت يدويًّا فجاءت بطول 54 محرفًا.
الطول وحالة الأحرف هما شكلا العطل اللذان لا تراهما بالتحديق. الصق الترويسة في المحلّل فيسمّي لك القاعدة التي خُولفت، بدل أن يجعلك تعدّ الخانات.
tracestate: الترويسة الرفيقة التي يخطئ فيها الجميع
تحمل traceparent الهوية المعيارية. أما ترويسة tracestate فتحمل ما يشاء كل مورّد أن يضيفه إلى جانبها، على شكل أعضاء key=value تفصل بينها فواصل:
tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
وأي تطبيق لا يتعرّف على مفتاح ما عليه أن يمرّره كما هو. وهذا هو هدف التصميم كله: أن يتمكن الموردون من إركاب حالة خاصة بهم على تتبّع معياري دون أن تحتاج كل قفزة إلى فهمها.
غير أن للصياغة أنيابًا، وبعض قواعدها يفسّر أعراضًا حقيقية في الإنتاج.
اثنان وثلاثون عضوًا سقفٌ صارم. هذه ليست نصيحة، بل هي الصياغة نفسها: list = list-member 0*31( OWS "," OWS list-member ). فـ tracestate بثلاثة وثلاثين عضوًا ليست tracestate بمدخل زائد، بل ترويسة غير صالحة، وللمستقبِلات أن تُسقطها بأكملها. وهذا هو تفسير عَرَض يبدو لولا ذلك سحرًا: بيانات مورّد حاضرة عند الطرف، وحاضرة بعد قفزتين، وغائبة تمامًا عند القفزة الخامسة. كانت كل قفزة تُلحق عضوها الخاص، فتجاوزت القائمة 32، ومن تلك اللحظة صارت الترويسة كلها تُسقَط لا تُقلَّم.
القيم من محرف واحد إلى 256 محرفًا، ولا يمكن أن تكون فارغة أبدًا. فإنتاج القيمة ينتهي بمحرف غير فارغ إلزامي، ومن ثمّ فـ vendor= ليست «مفتاحًا بلا قيمة» بل خطأ صياغيًّا. ومحارف ASCII القابلة للطباعة فقط، ولا فاصلة ولا علامة يساوٍ داخل القيمة أبدًا.
صياغة المفاتيح تغيّرت بين المستوى الأول والمستوى الثاني. عرّف المستوى الأول المفاتيح عبر إنتاج tenant@vendor، حيث كان @ فاصلًا بنيويًّا. واستبدل المستوى الثاني بذلك صنف محارف مسطّحًا: يبدأ المفتاح بحرف صغير أو رقم ويستمر بـ a-z و0-9 و_ و- و* و/ و@. وتحت المستوى الثاني صار @ محرفًا عاديًّا، وجاز أن تبدأ المفاتيح برقم، وصار a@b@c مفتاحًا سليمًا تمامًا كان إنتاج المستوى الأول ليرفضه. فإن كان لديك وكيل يتحقق وفق المستوى الأول وخدمة تصدر مفاتيح المستوى الثاني، قبِل أحد الطرفين ما يرفضه الآخر، واختفت الترويسة عند قفزة واحدة بالضبط.
وقاعدتان أخريان تستحقان المعرفة. المفاتيح المكرّرة غير صالحة قطعًا. وحين تعدّل الـ parent-id في الـ traceparent، عليك أن تنقل مدخلك في الـ tracestate إلى مقدمة القائمة، فالقائمة مرتّبة من الأحدث إلى الأقدم. وتخطّي خطوة النقل إلى المقدمة يترك حالة مورّد قديمة في موضع سيعاملها القارئ على أنها الحالية.
وأخيرًا القاعدة الودودة: الأعضاء الفارغون مسموح بهم. فحين يزيل صندوق وسيط مدخلًا، كثيرًا ما يترك الفاصلة خلفه، فينتج rojo=1,,congo=2. والمواصفة تجيز هذا صراحةً، فعلى المحلّل أن يُسقط العضو الفارغ ويتابع بدل أن يعلن الترويسة مشوّهة. وعرض tracestate في المحلّل يسرد كل عضو مع تحقق لكل عضو على حدة وعدّاد جارٍ مقابل حدّ الـ 32 عضوًا، وهو عادةً أسرع من عدّ الفواصل.
كيف تسافر ترويسات التتبع الموزّع: طلب واحد، أربع قفزات
تابِع طلبًا واحدًا عبر وكيل طرفي وخدمة API وخدمتين في المصبّ:
Client
│ (no traceparent — the edge is the root)
▼
Edge proxy generates trace-id 4bf9…4736, span 00f0…02b7
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
▼
API service reads it, creates span a1b2c3d4e5f60718
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01
▼
Orders service reads it, creates span 9f8e7d6c5b4a3928
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01
▼
Inventory service
وكل قفزة تفعل الأشياء الثلاثة نفسها: تقرأ الترويسة الواردة، وتستبدل الـ parent-id بمعرّف الـ span الخاص بها عند كل استدعاء صادر، وتمرّر الـ trace-id والرايات بلا تغيير. وحين لا توجد ترويسة واردة أصلًا (كحالة العميل أعلاه) تكون الخدمة المستقبِلة هي الجذر: تولّد الـ trace-id وتتخذ قرار أخذ العيّنة لكل ما دونها.
ويمكنك حقن ترويسة يدويًّا لاختبار سلسلة كاملة من طرف إلى طرف:
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
https://example.com/api
أعِد تشغيل ترويسة ملتقطة من الإنتاج على بيئة الاختبار وستشاهد الـ trace-id نفسه يظهر في نظامك الخلفي. ومنشئ أوامر cURL يجمّع لك الرايات إن كنت تضيف مصادقة أو جسم طلب، وورقة غش curl تغطي خيارات الترويسات والإسهاب التي ستحتاجها أثناء التنقيح.
ولترى ما استقبلته الخدمة فعلًا لا ما تظن أنك أرسلته، شغّل خادم صدى مؤقتًا ووجّه إليه قفزة واحدة:
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Echo(BaseHTTPRequestHandler):
def do_GET(self):
for name, value in self.headers.items():
print(f"{name}: {value}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY
ثم نفّذ curl -H 'traceparent: …' http://127.0.0.1:8080/ واقرأ ما خرج من الجهة الأخرى. نصف تحقيقات «الوكيل يلتهم ترويستي» تنتهي هنا.
راية sampled في الـ trace-flags: قرار من الأعلى لا إيصال
بت sampled بقيمة 1 في trace-flags يعني أن الخدمة الأعلى قرّرت تسجيل هذا التتبع. لكنه لا يعد بأن البيانات وصلت إلى نظامك الخلفي.
أخذ العيّنات عند الرأس يتخذ ذلك القرار عند الجذر، قبل أن يحدث أي شيء، ثم ينشره إلى الأسفل: رخيص، ومتّسق عبر الخدمات، وأعمى، إذ لا سبيل له إلى معرفة أن الطلب كان على وشك الفشل. أما أخذ العيّنات عند الذيل فيخزّن الـ spans مؤقتًا حتى يكتمل التتبّع ثم يقرّر، فيستطيع الاحتفاظ بكل تتبّع يحوي خطأ، مقابل إبقاء الـ spans في الذاكرة والحاجة إلى أن تصل spans كل خدمة إلى المجمّع نفسه.
وتحت أخذ العيّنات عند الذيل قد يصل تتبّع موسومًا بـ 01 عند كل قفزة ثم يُسقَط في النهاية. وحدود المعدّل وحصص التصدير قد تُسقطه أيضًا. فوجود 01 عند الطرف وغياب التتبّع من الواجهة ليس بالضرورة عطلًا في النشر؛ افحص مقاييس الإسقاط لدى المجمّع نفسه قبل أن تذهب لتفتيش الترويسات.
والحالة المعاكسة أهم في العمل اليومي. فإن كانت الرايات الواردة 00، فالمستدعي شغّل آخذ العيّنات لديه واختار ألّا يسجّل. لا شيء في خدمتك مضبوط خطأً، وتدقيق آخذ العيّنات عندك مضيعة للوقت؛ السؤال هو أي خدمة أعلى في السلسلة تقرّر عدم أخذ العيّنة.
التحويل بين صيغ النشر
انتصر W3C Trace Context، لكن كثيرًا من الأنظمة ما زال يتكلم لغةً أقدم، والبوابات تترجم بينها. وهذا مثال الـ traceparent نفسه مكتوبًا بأربع صيغ:
| الصيغة | الترويسة/الترويسات | القيمة في مثالنا |
|---|---|---|
| W3C | traceparent | 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 |
| B3 مفردة | b3 | 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1 |
| B3 متعددة | X-B3-TraceId، X-B3-SpanId، X-B3-Sampled | 4bf92f3577b34da6a3ce929d0e0e4736، 00f067aa0ba902b7، 1 |
| Datadog | x-datadog-trace-id، x-datadog-parent-id، والوسم _dd.p.tid | 11803532876627986230، 67667974448284343، 4bf92f3577b34da6 |
| AWS X-Ray | X-Amzn-Trace-Id | Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1 |
Datadog: انقسام الـ 64 بت العليا والدنيا
معرّفات Datadog أقدم من معرّفات التتبع ذات الـ 128 بت، وطبقة التوافق هي موضع خطأ معظم التحويلات. فـ x-datadog-trace-id يحمل الـ 64 بت الدنيا كسلسلة عشرية. أما الـ 64 بت العليا فتسافر منفصلة، بالست عشري، في الوسم _dd.p.tid، الذي يسافر بدوره داخل ترويسة x-datadog-tags.
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');
const datadogTraceId = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);
console.log('x-datadog-trace-id:', datadogTraceId); // 11803532876627986230
console.log('x-datadog-tags:', higher64Hex); // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343
والخطأ الكلاسيكي هو تحويل الـ 128 بت كلها إلى عدد عشري واحد:
BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 — matches nothing in the UI
تلك القيمة ليست حسابًا خاطئًا. إنها التمثيل العشري الصحيح للمقدار الخطأ، ولهذا تنجو من المراجعة ثم لا تطابق أي تتبّع في صمت.
والمصيدة الثانية هي دقة الأعداد. فمعرّف بطول 64 بت يتجاوز Number.MAX_SAFE_INTEGER، وقيمته 9007199254740991، ومن ثمّ فأي مسار في الشيفرة يسمح لمعرّف تتبع أن يصير عددًا في JavaScript يُفسد خاناته الدنيا. أبقِ معرّفات التتبع سلاسل نصية ولا تلجأ إلى BigInt إلا حين تضطر إلى إجراء حساب؛ والمعرّف الذي يصلك في JSON بلا علامات اقتباس يكون قد تلف قبل أن تراه.
AWS X-Ray: الطابع الزمني غير الموجود
معرّف التتبع في X-Ray يبدو هكذا 1-{8 hex}-{24 hex}، والخانات الست عشرية الثماني الأولى هي وقت الإنشاء بثواني الحقبة. والتحويل من W3C آليّ:
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex = traceId.slice(0, 8); // 4bf92f35
const epochSeconds = parseInt(epochHex, 16); // 1274621749
const xrayId = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1
new Date(epochSeconds * 1000).toISOString(); // 2010-05-23T13:35:49.000Z
انظر إلى ذلك التاريخ. ترويسة المثال في المواصفة تُفَكّ إلى مايو 2010، وهو هراء ظاهر، وهذا هو المقصود. الـ trace-id في W3C لا يحوي طابعًا زمنيًّا. فستة عشر بايتًا عشوائيًّا ستنتج بكل سرور حقبةً معقولة المظهر حين تقرأ أول أربعة منها كعدد واحد، والرقم لا يعني شيئًا ما لم يكن المعرّف قد نشأ فعلًا في X-Ray. واستخراج وقت من معرّف تتبع اعتباطي هو قراءة عدد عشوائي وتصديقه.
وحين يكون المعرّف قادمًا من X-Ray حقًّا، يصير التحويل مفيدًا: ألقِ تلك الخانات الست عشرية الثماني في محوّل Unix Timestamp لتحصل على تاريخ مقروء، ودليل طابع Unix الزمني يغطي مصائد الثواني مقابل الأجزاء من الألف والمنطقة الزمنية التي تتبع ذلك.
B3: سلالة Zipkin
جاءت B3 من Zipkin وهي الصيغة التي تصادفها في شبكات الخدمات الأقدم. صيغة الترويسة المفردة هي traceId-spanId-sampled، حيث حقل sampled هو 1 أو 0 لا بايت ست عشري، ومن ثمّ لا يجد بت random-trace-id من المستوى الثاني مكانًا يذهب إليه فيضيع ببساطة في الترجمة. أما صيغة الترويسات المتعددة فتوزّع القيم نفسها على X-B3-TraceId وX-B3-SpanId وX-B3-Sampled.
والإرث التاريخي المزعج هنا هو العرض. فمعرّفات التتبع في B3 قد تكون بطول 64 بت، أي 16 خانة ست عشرية بدل 32. وتحويل معرّف B3 بطول 64 بت إلى W3C يعني الحشو بالأصفار من اليسار حتى بلوغ 32 خانة، والتحويل العكسي يعني أن تقرّر إن كنت ستبتر. الحشو من اليسار آمن؛ والبتر ليس كذلك، لأن تتبّعين لا يختلفان إلا في بايتاتهما العليا ينهاران إلى واحد.
أين تضيع الـ traceparent في الإنتاج
كل ما سبق يفترض أن الترويسة تصل. وكثيرًا ما لا تصل. وهذه أربعة مواضع تختفي فيها.
المتصفح يُسقطها في الاستدعاءات عابرة الأصل
العَرَض: توجد عمليات تتبع في الواجهة، وتوجد في الخلفية، ولا شيء يربط بينها. أو يفشل الطلب عابر الأصل صراحةً بخطأ CORS.
السبب: traceparent ترويسة مخصّصة، فإضافتها تجعل الطلب غير بسيط وتُطلق طلب استطلاع OPTIONS. وإن لم تُدرج استجابة الاستطلاع من الخادم الترويسةَ في Access-Control-Allow-Headers، حجب المتصفح الطلب الحقيقي. وبمعزل عن ذلك، ترفض أدوات OpenTelemetry في المتصفح حقن ترويسات التتبع في الطلبات عابرة الأصل ما لم تخبرها بالأصول المسموح بها.
العلاج: على الخادم، أعِد Access-Control-Allow-Headers: traceparent, tracestate للاستطلاع. وفي حزمة SDK الخاصة بالمتصفح، اضبط propagateTraceHeaderCorsUrls على نمط يطابق أصول واجهتك البرمجية. وكلاهما لازم؛ أيٌّ منهما وحده يتركك مع العَرَض نفسه. والاستطلاع الذي يعود بحالة غير متوقعة يستحق أن تراجعه على ورقة غش أكواد حالة HTTP قبل أن تفترض أن المشكلة في الترويسة.
الوكلاء وجدران حماية الويب وموازِنات الحمل تُجرّد الترويسات المجهولة
العَرَض: الترويسة حاضرة حين تنفّذ curl على الخدمة مباشرة، وغائبة حين يمرّ الطلب نفسه عبر البوابة.
السبب: التمرير القائم على قوائم السماح. فكثير من إعدادات الوكلاء ومجموعات قواعد جدران حماية الويب وموازنات الحمل المُدارة لا يمرّر إلا الترويسات التي يعرفها، وtraceparent ليست على القائمة الافتراضية. وبعض شبكات الخدمات يعيد كتابة الترويسة أيضًا، فيولّد معرّف تتبع خاصًّا به ويُسقط معرّفك.
العلاج: جزّئ المسار بخادم الصدى السابق: ضعه خلف كل قفزة بالتناوب وانظر أي طبقة تُسقط الترويسة. ثم اسمح صراحةً بـ traceparent وtracestate في قواعد التمرير لتلك الطبقة. وإن كان الوكيل هو nginx، فانتبه إلى أن الكتلة التي تعالج مسارًا ما هي التي تقرّر أي ترويسات تمرّرها، وأن الكتلة التي تعالج المسار ليست دائمًا التي تتوقعها؛ وقواعد أولوية location في Nginx تشرح لماذا قد يبدو إعداد الترويسات متجاهَلًا بالكامل.
طوابير الرسائل لا ترويسات HTTP فيها
العَرَض: ينتهي التتبع لحظة يتحول الطلب إلى مهمة خلفية.
السبب: لا يوجد طلب HTTP عبر ذلك الحدّ، فلا يوجد ما تُحمَل عليه الترويسة. لدى Kafka ترويسات سجلات، ولدى SQS سمات رسائل، ولا يملأ لك أيًّا منهما تجهيزُ HTTP.
العلاج: احقن السياق في الرسالة عند المنتِج واستخرجه عند المستهلِك. فكل حزم OpenTelemetry تكشف inject وextract لهذا الغرض، وصيغة السلك هي سلسلة W3C نفسها؛ الحامل وحده هو ما يتغير من خريطة ترويسات HTTP إلى بيانات وصفية للرسالة. وتوثيق ناشري السياق في OpenTelemetry يغطي واجهة الحامل لكل لغة.
حالة الأحرف، وما الذي يُصغّره HTTP/2 فعلًا
العَرَض: لبس في مراجعة الشيفرة حول ما إذا كانت Traceparent مقبولة.
السبب: قاعدتان منفصلتان تندمجان في واحدة. أسماء ترويسات HTTP/1.1 غير حسّاسة لحالة الأحرف، وHTTP/2 يشترط ترميزها بأحرف صغيرة على السلك. هذا عن الاسم. وبمعزل عن ذلك، يجب أن يكون الست عشري في قيمة الترويسة بأحرف صغيرة، لأن صياغة W3C تقول ذلك، ولا إصدار بروتوكول سيصلح لك هذا.
العلاج: أرسل الاسم traceparent ولا ترفع حالة القيمة أبدًا. فالبوابة التي تُطبّع أسماء الترويسات لن تُطبّع خاناتك الست عشرية، ومعرّف تتبع بأحرف كبيرة يبحر عبر كل طبقات النقل قبل أن يرفضه التطبيق الذي يحلّله أخيرًا.
هل ينبغي أن تثق بـ traceparent واردة؟
الـ traceparent التي تصل من الإنترنت العام مدخلٌ يتحكم فيه المستخدم: سلسلة اختارها عميل مجهول، وتقبلها معظم الخدمات بلا تدقيق.
وتترتب على ذلك مخاطر ملموسة. أولًا لصق التتبع: فمهاجم يرسل معرّف تتبع رصده في مكان آخر يُدخل طلبه في تتبّع قائم، فيلوّث الرسم البياني وقد يكشف توقيتات داخلية لمن يستطيع قراءة ذلك التتبع. ثانيًا حرق الحصة: فتثبيت 01 يفرض أخذ العيّنة على كل طلب، ويتحول سيل متواضع إلى فاتورة استيعاب ضخمة، أو أسوأ، إلى إزاحة عمليات التتبع التي كنت تحتاجها فعلًا. ثالثًا الربط عبر المستأجرين: فإعادة استخدام معرّف تتبع واحد عبر طلبات من مستأجرين مختلفين تربط سجلات تعاملها أدواتك بعد ذلك بوصفها عملية منطقية واحدة.
والموقف العملي هو القبول عند الطرف مع عدم الوثوق. تحقّق من الصياغة وارفض الترويسات المشوّهة بدل تمريرها إلى الداخل. وللحركة غير المصادَق عليها، أعِد تشغيل قرار أخذ العيّنات الخاص بك بدل احترام الراية الواردة، حتى لا يستطيع أي عميل خارجي أن يثبّت آخذ العيّنات لديك على «سجّل دائمًا». أما الحركة المصادَق عليها فاحترام قرار المستدعي فيها مقبول عادةً، لأنك تعرف من هو.
وعامِل الـ trace-id على أنه علني. فهو ليس سرًّا ولم يكن كذلك قط: يظهر في السجلات، وفي صفحات الأخطاء، وفي ترويسات الاستجابة، وفي لقطات الشاشة الملصقة في تذاكر الدعم. لا تُرمّز فيه أبدًا معرّف مستخدم أو اسم مستأجر أو أي شيء ذي معنى، ولا تستخدمه أبدًا مفتاح تخويل. إنه معرّف ارتباط، وهذا كل ما ينبغي أن يكونه.
الأسئلة الشائعة
ما الفرق بين traceparent وtracestate؟
traceparent تحمل الهوية المعيارية (الـ trace-id والـ parent-id ورايات أخذ العيّنات)، وعلى كل تطبيق أن يفهمها. أما tracestate فتحمل حالة خاصة بالمورّد تمرّرها التطبيقات غير المُلمّة بها كما هي. والاثنتان مرتبطتان: فحين تكون الـ traceparent غير صالحة، تُلزم المواصفة بتجاهل الـ tracestate كذلك.
لماذا يبدأ التتبع عندي من جديد في منتصف سلسلة الاستدعاءات؟
يبدأ التتبع من جديد في منتصف السلسلة في الغالب الأعم لأن قفزة ما استقبلت ترويسة سقطت في اختبار الصياغة، فأسقطتها وولّدت معرّف تتبع جديدًا. والست عشري بأحرف كبيرة، ومعرّف التتبع الصِّفري، والترويسة التي ليست 55 محرفًا بالضبط، كلها تسبب ذلك. وإن كانت الترويسة سليمة البنية، فالمشتبه بهما التاليان وكيلٌ يجرّدها واستطلاعٌ عابر أصل يفشل.
هل أحتاج إلى ضبط CORS لإرسال traceparent من متصفح؟
نعم، ضبط CORS مطلوب. إنها ترويسة مخصّصة، فتجعل الطلب غير بسيط وتُطلق استطلاعًا؛ وعلى الخادم أن يُدرج traceparent في Access-Control-Allow-Headers. كما تحتاج أدوات OpenTelemetry في المتصفح إضافةً إلى ذلك ضبط propagateTraceHeaderCorsUrls، لأنها لن تحقن ترويسات التتبع عبر الأصول افتراضيًّا.
كيف أنشر سياق التتبع عبر Kafka أو SQS؟
اكتب قيمة الـ traceparent في ترويسة سجل Kafka أو في سمة رسالة SQS عند المنتِج، واقرأها عند المستهلِك لاستعادة السياق. وحزم OpenTelemetry تكشف inject وextract لهذا في كل لغة. والصيغة لا تتغير؛ الحامل وحده هو ما يختلف عن خريطة ترويسات HTTP.
هل من الآمن كشف معرّف التتبع في السجلات أو الاستجابات؟
نعم، كشف معرّف التتبع آمن. إنه معرّف عشوائي بلا هوية مضمّنة وبلا سلطة تخويل. لكنه يربط سجلات عبر الأنظمة، فلا تُرمّز فيه أبدًا معرّف مستخدم أو اسم مستأجر، ولا تقبله أبدًا دليلًا على أي شيء. عامله مفتاحَ ارتباط علنيًّا، وتسجيله وإرجاعه في الاستجابات آمنان.
مَن يولّد ترويسة traceparent؟
أول خدمة تعالج طلبًا يصل بلا ترويسة traceparent. وهي عادةً وكيل طرفي أو بوابة API أو حزمة SDK في المتصفح، وتصير جذر التتبع: تولّد الـ trace-id، وتنشئ أول span، وتتخذ قرار أخذ العيّنة، ثم لا تفعل كل قفزة بعدها سوى إعادة كتابة الـ parent-id.
هل ترويسة traceparent إلزامية؟
لا، الترويسة اختيارية على مستوى البروتوكول، والطلب الذي يصل بلا traceparent طلب سليم تمامًا؛ تصير الخدمة المستقبِلة عندئذٍ جذر تتبّع جديد. وهي إلزامية بمعنى عملي واحد فقط: بدونها لا سبيل إلى ربط العمل المنجز على جانبي الحدّ بين خدمتين في تتبّع واحد.
هل تضيف الـ traceparent حِملًا ملموسًا؟
ليس بأي قدر يُذكر. فترويسة traceparent 55 بايتًا، والـ tracestate تضيف عادةً بضع مئات أخرى، وهو لا شيء إلى جانب مصافحة TLS أو أي حمولة حقيقية. والتكلفة الفعلية للتتبع هي تصدير الـ spans المأخوذة بالعيّنة وتخزينها، لا حمل ترويسات التتبع الموزّع على السلك.