এই পর্বে যা শিখবেন
- একটি ধীর request ধরতে metric আর log কোথায় থেমে যায়
- Span-এর গঠন আর
traceparentheader-এর প্রতিটি অংশ পড়া - আসল run-এ তিন service-এর trace Jaeger থেকে পড়ে ধীর span খোঁজা
- Manual আর auto instrumentation, OTLP আর Collector-এর pipeline
- Head বনাম tail sampling-এর হিসাব, exemplar, PII, queue আর খরচ
১ · একটি ধীর request: metric আর log কোথায় থেমে যায়
একটি কাল্পনিক অনলাইন বাজার অ্যাপের কথা ভাবুন। ক্রেতা “অর্ডার করুন” চাপলেন। অনুরোধটি গেল shop-api-তে, সেখান থেকে order-svc-এ, তারপর payment-svc-এ, শেষে ব্যাংকের gateway-তে। এটি microservice ধরনের নকশা। একটি ক্লিকের পেছনে চারটি জায়গায় কাজ হচ্ছে।
রাত ৯টায় অভিযোগ এলো, “চেকআউট মাঝে মাঝে ঝুলে থাকে।” আপনি কী কী দেখতে পারেন?
| সংকেত | যা বলে | যা বলে না |
|---|---|---|
| Metric (পর্ব ৬৩) | shop-api-র p99 ১.২ সেকেন্ড হয়েছে | কোন request, আর সময়টা কোন service-এ গেল |
| Log (পর্ব ৬৪) | প্রতিটি service আলাদাভাবে কী লিখেছে | কোন লাইনগুলো একই request-এর, যদি কোনো id না থাকে |
| Trace | একটি request-এর পুরো পথ আর প্রতিটি ধাপের সময় | সার্বিক হার। সেটি metric-এর কাজ |
সমস্যাটি সবচেয়ে কঠিন হয় যখন প্রতিটি service-এর নিজের metric স্বাভাবিক দেখায়। order-svc-এর গড় ঠিক আছে, কারণ এক হাজারের মধ্যে অল্প কয়েকটি request ধীর। গড় আর সারাংশ ওই কয়েকটিকে গিলে ফেলে। আবার log-এ হাজার হাজার লাইন, এক সেকেন্ডে অনেক request-এর লাইন মিশে আছে। কোনটি কার, বোঝার উপায় নেই।
ধরুন ঢাকা থেকে চট্টগ্রামে একটি পার্সেল পাঠালেন, আর সেটি দুই দিন দেরিতে পৌঁছাল। কুরিয়ারের দৈনিক হিসাব (metric) বলে, আজ গড় ডেলিভারি ৩০ ঘণ্টা। প্রতিটি হাবের খাতা (log) আলাদা, সেখানে হাজারো পার্সেলের নাম। কিন্তু আপনার পার্সেলের tracking number থাকলে সব হাবের খাতা এক সুতোয় গাঁথা যায়: ঢাকা হাবে ২ ঘণ্টা, কুমিল্লা হাবে ৪৫ ঘণ্টা, চট্টগ্রামে ১ ঘণ্টা। দেরি কোথায়, এক নজরেই পরিষ্কার। Distributed tracing হলো সফটওয়্যারের সেই tracking number.
Metric, log আর trace মিলে observability-র তিনটি প্রধান সংকেত। তত্ত্বের দিকটি System Design · Observability-তে আছে। এই পর্বে আমরা চালানোর দিকটি দেখব: কীভাবে বসাবেন, পড়বেন, ছাঁটবেন আর খরচ সামলাবেন।
২০১০ সালে Google প্রকাশ করে Dapper paper: Benjamin Sigelman ও সহলেখকদের “Dapper, a Large-Scale Distributed Systems Tracing Infrastructure”. Dapper-এর ধারণা অনুসরণ করে Twitter ২০১২ সালে open-source করে Zipkin. Uber বানায় Jaeger, যা ২০১৭ সালে open-source হয়ে CNCF-এ যায়। ২০১৬ সালে আসে OpenTracing, একটি সাধারণ API-র চেষ্টা। Google-এর দিক থেকে আসে OpenCensus. দুটি প্রকল্প একই কাজে ভাগ হয়ে যাচ্ছিল, তাই ২০১৯ সালে তারা মিশে হয় OpenTelemetry, সংক্ষেপে OTel. এটি এখন একটি CNCF প্রকল্প।
২ · Trace, span আর parent: শব্দগুলো চিনে নিই
একটি request-এর পুরো রেকর্ড হলো trace. তার প্রতিটি কাজের টুকরো হলো span. প্রতিটি span জানে তার trace id, নিজের span id আর তার parent-এর span id. এই তিনটি দিয়েই আলাদা machine-এ তৈরি span পরে একটি গাছ হয়ে যায়। যে span-এর parent নেই, সেটি root span.
| অংশ | অর্থ | আমাদের run-এর উদাহরণ |
|---|---|---|
| নাম আর সময় | কী কাজ, কখন শুরু, কতক্ষণ | bank.authorize, ১২০০.১ ms |
| Kind | span-এর ভূমিকা | server, client, internal |
| Attribute | key-value তথ্য, যা ধরে খোঁজা যায় | peer.service=bank-gateway |
| Event | span-এর ভেতরের একটি মুহূর্ত | bank retry, attempt=2 |
| Status | Unset, Ok বা Error | code: 2 মানে Error |
Span-গুলো সময়ের দাগে পাশাপাশি আঁকলে যে ছবি হয়, তাকে বলে waterfall. প্রতিটি বারের বাঁ প্রান্ত শুরু, ডান প্রান্ত শেষ, আর নিচের বার ওপরের বারের ভেতরে থাকলে সেটি child. নিচের ছবিটি আমাদের আসল run-এর একটি ধীর request থেকে আঁকা:
একটি trace-এর waterfall (আসল run, মোট ১২২৬.৯ ms)
লাল বারটিই প্রায় পুরো সময় খেয়েছে
একটি span-এর self time = তার মোট সময় − তার child-দের সময়। Waterfall পড়ার নিয়ম হলো সবচেয়ে লম্বা বার নয়, সবচেয়ে বেশি self time খোঁজা। ওপরের ছবিতে POST /checkout-এর self time মাত্র ১.০ ms.
৩ · Context propagation: traceparent header পড়া
Span তৈরি হচ্ছে তিনটি আলাদা process-এ। তাহলে payment-svc কীভাবে জানবে সে কোন trace-এর অংশ? উত্তর সহজ: আগের service তাকে বলে দেয়। এই কাজের নাম context propagation. HTTP-তে তথ্যটি যায় একটি header-এ, যার নিয়ম ঠিক করেছে W3C-র Trace Context মান (Recommendation, ২০২০)। Header-টির নাম traceparent. আমাদের run থেকে একটি আসল মান:
traceparent: 00-42a322d43ae0d3f1b5f444ced406e897-0892dd39cd3d82fc-03| অংশ | দৈর্ঘ্য | মান | অর্থ |
|---|---|---|---|
| version | ২ hex | 00 | ফরম্যাটের সংস্করণ। ff নিষিদ্ধ |
| trace-id | ৩২ hex (১৬ byte) | 42a3…e897 | পুরো পথে একই থাকে। সব শূন্য হলে অবৈধ |
| parent-id | ১৬ hex (৮ byte) | 0892…82fc | পাঠানো span-এর id. প্রতি hop-এ বদলায়। সব শূন্য হলে অবৈধ |
| flags | ২ hex | 03 | bit 0 = sampled. bit 1 = trace id random |
03 মানে binary 0000 0011: sampled-এর bit চালু, আর পাশের bit-ও চালু। দ্বিতীয় bit-টি Trace Context Level 2-এর random-trace-id flag, যা বলে trace id-র ডানের অন্তত ৭ byte দৈব। Level 2 লেখার সময়ে W3C-র Candidate Recommendation Draft, চূড়ান্ত মান নয়। আমাদের run-এ SDK নিজে থেকেই এটি বসিয়েছে। বেশিরভাগ ব্যাখ্যায় আপনি -01 দেখবেন। -03 দেখে ঘাবড়াবেন না, প্রথম bit-ই sampling-এর কথা বলে। Head sampling-এর run-এ আমরা বাদ পড়া request-এ -02 পেয়েছি: sampled বন্ধ, random চালু।
পাশে থাকতে পারে tracestate header, যেখানে কোনো vendor নিজের তথ্য রাখে। আর আছে baggage, যা আপনার নিজের key-value পরের সব service-এ নিয়ে যায়। নিচের stepper-এ আমাদের run-এর আসল id দিয়ে দেখুন context কীভাবে তিনটি service পার হলো। খেয়াল করুন, trace-id স্থির, parent-id বদলাচ্ছে:
- shop-api
server + client spantrace এখানে শুরু - order-svc
server + client spanheader পড়ে, নতুন span - payment-svc
server spanভেতরের কাজ
incoming traceparent : (none)
new trace id : 42a322d43ae0d3f1b5f444ced406e897
server span : 80b607806f20610c parent: none (root)
client span : 0892dd39cd3d82fc parent: 80b607806f20610c
header : 00-42a322d43ae0d3f1b5f444ced406e897
-0892dd39cd3d82fc-03
trace id : 42a322d43ae0d3f1b5f444ced406e897 (same)
server span : d1e45ffe95da6303
parent : 0892dd39cd3d82fc (shop-api's client span)
client span : 5a182c62770cc9d9 parent: d1e45ffe95da6303
header : 00-42a322d43ae0d3f1b5f444ced406e897
-5a182c62770cc9d9-03
trace id : 42a322d43ae0d3f1b5f444ced406e897 (same)
server span : 857c626356151570
parent : 5a182c62770cc9d9 (order-svc's client span)
fraud.check : 9abfc62b47d3e471 parent: 857c626356151570
bank.authorize : a04d2e3809e4d14e parent: 857c626356151570
each process exports its spans to the Collector by OTLP
- ধাপ ১ — শুরু।
shop-api-তে কোনোtraceparentআসেনি, log-এnull. তাই SDK নতুন trace id বানাল, আর80b6…610cহলো root span. - ধাপ ২ — Inject.
order-svc-কে ডাকার আগে একটি client span0892…82fcতৈরি হলো। SDK header-এ এই id বসাল। অর্থাৎ পরের service-এর parent হবে এই client span. - ধাপ ৩ — Extract.
order-svcheader পড়ল। Trace id আগেরটিই রইল। তার server spand1e4…6303-এর parent হলো0892…82fc. - ধাপ ৪ — আবার inject.
order-svcতার নিজের client span5a18…c9d9দিয়ে নতুন header বানাল। Trace id একই, parent-id নতুন। - ধাপ ৫ — আবার extract.
payment-svc-এর server span857c…1570, parent5a18…c9d9. - ধাপ ৬ — একই process-এর ভেতরে।
fraud.checkআরbank.authorizeএকই process-এ, তাই header লাগেনি। Parent এসেছে memory-র context থেকে। প্রতিটি process নিজের span আলাদাভাবে পাঠায়, backend trace id ধরে জোড়া দেয়।
এবার নিজে পরীক্ষা করুন। নিচের কোড header পড়ে, যাচাই করে, আর পরের hop-এর header বানায়। নতুন id আসে seeded PRNG থেকে, তাই প্রতিবার একই ফল পাবেন। আসল SDK-তে এখানে cryptographically ভালো random উৎস লাগে।
// Parse a W3C traceparent header and build the header for the next hop.
function mulberry32(seed) {
return function () {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(65); // fixed seed: same ids on every run
const hex = n => Array.from({ length: n },
() => Math.floor(rand() * 16).toString(16)).join('');
function parse(h) {
const m = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(h);
if (!m) return { ok: false, why: 'wrong shape' };
const [, version, traceId, parentId, flags] = m;
if (version === 'ff') return { ok: false, why: 'version ff is forbidden' };
if (/^0+$/.test(traceId)) return { ok: false, why: 'trace-id is all zero' };
if (/^0+$/.test(parentId)) return { ok: false, why: 'parent-id is all zero' };
return { ok: true, traceId, parentId, flags, sampled: (parseInt(flags, 16) & 1) === 1 };
}
const child = c => `00-${c.traceId}-${hex(16)}-${c.flags}`;
const headers = [
'00-42a322d43ae0d3f1b5f444ced406e897-0892dd39cd3d82fc-03',
'00-c8351ad671afbc3e94d7ca250e0da4ae-c8b92d09406496cd-02',
'00-00000000000000000000000000000000-0892dd39cd3d82fc-01',
'00-42A322D43AE0D3F1B5F444CED406E897-0892dd39cd3d82fc-01',
];
for (const h of headers) {
const c = parse(h);
if (!c.ok) { console.log('REJECT ' + c.why + ' -> start a new trace'); continue; }
console.log(`sampled=${c.sampled} from parent ${c.parentId}`);
console.log(' next hop: ' + child(c));
}প্রথম দুটি header আমাদের run-এর আসল মান। আউটপুট: sampled=true from parent 0892dd39cd3d82fc, তারপর next hop: 00-42a3…e897-169b53fd923ed6ec-03. দ্বিতীয়টিতে sampled=false, কারণ 02-এর bit 0 বন্ধ। তৃতীয়টি REJECT trace-id is all zero -> start a new trace. চতুর্থটি REJECT wrong shape, কারণ মান অনুযায়ী hex হবে ছোট হাতের অক্ষরে। বাস্তবে এর ফল হলো trace দুই টুকরো হয়ে যাওয়া: পরের service নতুন trace শুরু করে, আর waterfall মাঝপথে ছিঁড়ে যায়।
Trace ছিঁড়ে যাওয়ার সবচেয়ে সাধারণ কারণ হলো মাঝের কোনো proxy বা gateway header ফেলে দেওয়া, অথবা কোডে নিজে HTTP ডাকার সময় header না পাঠানো। Waterfall-এ যদি একটি service হঠাৎ root span নিয়ে আলাদা trace হয়ে যায়, আগে এই দুটি যাচাই করুন। আরও মনে রাখুন, বাইরের অচেনা client-এর পাঠানো traceparent সরাসরি বিশ্বাস করা ঠিক নয়। সীমান্তে নতুন trace শুরু করে আগেরটিকে span link হিসেবে রাখা একটি নিরাপদ অভ্যাস।
৪ · আসল run: তিনটি service, Jaeger আর ধীর span
এবার কথা ছেড়ে হাতে দেখি। এই পর্বের সব আউটপুট একটি আসল run থেকে নেওয়া: Docker Engine 29 আর BuildKit, Python 3.12, OpenTelemetry Python SDK 1.45.2, Collector contrib 0.164.0 আর Jaeger 2.22.0. ID, সময় আর সংস্করণ আপনার machine-এ আলাদা হবে। একটি ছোট Flask ফাইল তিনটি container-এ তিনটি service হিসেবে চলছে, আর OpenTelemetry-র Python প্যাকেজগুলো image build-এর সময় pip install দিয়ে বসানো। ধীর হওয়া আর ব্যর্থ হওয়া আমরা ইচ্ছা করে বসিয়েছি, যাতে ফল যাচাই করা যায়।
# payment-svc: the two manual spans. Flask and requests are auto-traced.
with tracer.start_as_current_span("fraud.check"):
time.sleep(0.008)
with tracer.start_as_current_span("bank.authorize") as s:
s.set_attribute("peer.service", "bank-gateway")
if body.get("mode") == "slow": # planted delay for the demo
s.add_event("bank retry", {"attempt": 2, "reason": "connect timeout"})
time.sleep(1.2)
elif body.get("mode") == "fail": # planted failure for the demo
err = RuntimeError("bank refused: gateway 503")
s.record_exception(err)
s.set_status(Status(StatusCode.ERROR, "bank refused"))
return jsonify(error="bank refused"), 502
else:
time.sleep(0.03)এক ক্রেতার request পাঠাই, আর উত্তরে আসা trace id দিয়ে Jaeger-এর HTTP API পড়ি। এই সংস্করণে JSON API আছে /api/v3/-এর নিচে, উত্তরের আকার OTLP-র মতো। পুরোনো /api/services এই run-এ 404 দিয়েছে। Tool-এর পথ সংস্করণভেদে বদলায়, তাই docs দেখে নেবেন।
$ curl -s -X POST -H 'Content-Type: application/json' \
-d '{"mode":"slow","method":"bkash"}' \
-w ' [%{http_code} %{time_total}s]\n' http://127.0.0.1:18652/checkout
{"sampled":true,"status":200,"trace_id":"42a322d43ae0d3f1b5f444ced406e897"}
[200 1.340501s]
$ curl -s http://127.0.0.1:18651/api/v3/services
{"services":["payment-svc","shop-api","order-svc"]}
$ python read_trace.py 42a322d43ae0d3f1b5f444ced406e897
8 spans, 1226.9 ms
0.0 1226.9 shop-api POST /checkout
0.8 1225.9 shop-api POST
1.9 1224.2 order-svc POST /orders
2.5 12.1 order-svc db.insert orders
15.1 1210.6 order-svc POST
16.3 1209.0 payment-svc POST /pay
16.8 8.0 payment-svc fraud.check
24.9 1200.1 payment-svc bank.authorize
slowest work: payment-svc / bank.authorize = 1200.1 msপ্রথম সংখ্যা trace শুরুর পর কত ms-এ span শুরু হলো, দ্বিতীয়টি span-এর দৈর্ঘ্য। read_trace.py ২৪ লাইনের একটি ছোট script, যা /api/v3/traces/<id> পড়ে span সাজায়। লক্ষ করুন, curl বলছে ১.৩৪০৫ সেকেন্ড, trace বলছে ১২২৬.৯ ms. বাকি ১১৩.৬ ms trace-এর বাইরে, কারণ trace শুধু instrument করা অংশটুকুই দেখে। Client থেকে server পর্যন্ত আসার পথ এখানে ধরা পড়েনি।
ID না জানলেও চলে। Jaeger-কে সময় ধরে জিজ্ঞেস করা যায়: query.duration_min=500ms দিলে এই run-এ ৪টি request-এর মধ্যে ঠিক একটি trace ফিরেছে, ওই 42a3…e897. এবার ধীর request-টি ধাপে ধাপে পড়ি:
normal request : 0.08 s
this request : 1.34 s
metric says only: p99 went up
query.service_name=shop-api
query.duration_min=500ms
-> 1 trace: 42a322d43ae0d3f1b5f444ced406e897
1226.9 shop-api POST /checkout
1225.9 shop-api POST
1224.2 order-svc POST /orders
1210.6 order-svc POST
1209.0 payment-svc POST /pay
each bar only waits for the bar below it
POST /checkout 1226.9 - 1225.9 = 1.0
POST (client) 1225.9 - 1224.2 = 1.7
POST /orders 1224.2 - 12.1 - 1210.6 = 1.5
POST (client) 1210.6 - 1209.0 = 1.6
POST /pay 1209.0 - 8.0 - 1200.1 = 0.9
db.insert orders = 12.1
fraud.check = 8.0
bank.authorize = 1200.1
1.0 + 1.7 + 1.5 + 1.6 + 0.9 + 12.1 + 8.0 + 1200.1
= 1226.9 ms (matches the root span)
bank.authorize share = 1200.1 / 1226.9 = 97.8%
bank.authorize
event: bank retry
attempt = 2
reason = connect timeout
normal request, same span: 30.1 ms
- ধাপ ১ — লক্ষণ। স্বাভাবিক request-এ client ০.০৮ সেকেন্ড পায়, এটিতে ১.৩৪ সেকেন্ড। Metric শুধু বলছে p99 বেড়েছে। কোথায়, সেটি বলছে না।
- ধাপ ২ — Trace খোঁজা।
shop-api-র যেসব trace ৫০০ ms-এর বেশি, সেগুলো চাইলাম। একটি trace পাওয়া গেল। ধরুন ক্রেতার অভিযোগে সময় জানা ছিল, তাহলে খোঁজা আরও সহজ। - ধাপ ৩ — ওপর থেকে নিচে। পাঁচটি বারই প্রায় ১২০০ ms-এর বেশি। নতুনরা এখানেই ভুল করেন, ভাবেন
shop-apiধীর। আসলে প্রতিটি বার নিচের বারের জন্য অপেক্ষা করছে। - ধাপ ৪ — Self time. প্রতিটি span থেকে child বাদ দিলাম। পাঁচটি অপেক্ষার span-এর প্রতিটির self time ২ ms-এর কম।
bank.authorize-এর ১২০০.১ ms আলাদা হয়ে চোখে পড়ল। - ধাপ ৫ — হিসাব মেলানো। সব self time যোগ করলে root span-এর ১২২৬.৯ ms-ই আসে।
bank.authorize-এর ভাগ ৯৭.৮%. অর্থাৎ পুরো পথের প্রায় সবটুকুই এই এক span. - ধাপ ৬ — কারণ। Span-এর event বলছে দ্বিতীয় চেষ্টা, কারণ
connect timeout. স্বাভাবিক request-এ একই span ছিল ৩০.১ ms. সমাধান খুঁজতে হবে ব্যাংকের সাথে সংযোগ আর timeout নীতিতে,shop-api-র CPU বাড়িয়ে নয়।
সমান্তরাল কাজে critical path
ওপরের trace ছিল সোজা লাইন। দুটি কাজ একসাথে চললে হিসাব বদলায়। সবচেয়ে লম্বা নির্ভরতার পথকে বলে critical path. এই পথের বাইরের span দ্রুত করলে মোট সময় একটুও কমে না। নিচের কোডে stock.reserve আর payment.pay একসাথে চলে। সংখ্যাগুলো কাল্পনিক, নিজে বদলে দেখুন।
// A waterfall from span data, with the critical path marked. Times in ms.
const spans = [
{ id: 'a', pid: null, name: 'POST /checkout', start: 0, dur: 640 },
{ id: 'b', pid: 'a', name: 'auth.verify', start: 5, dur: 30 },
{ id: 'c', pid: 'a', name: 'stock.reserve', start: 40, dur: 260 },
{ id: 'd', pid: 'a', name: 'payment.pay', start: 40, dur: 590 },
{ id: 'e', pid: 'd', name: 'fraud.check', start: 45, dur: 30 },
{ id: 'f', pid: 'd', name: 'bank.authorize', start: 80, dur: 530 },
];
const end = s => s.start + s.dur;
const kids = s => spans.filter(k => k.pid === s.id);
// Walk backwards: the child that finishes last before the cursor blocks us.
function walk(s, onPath) {
let cursor = end(s), self = 0;
for (const k of kids(s).sort((x, y) => end(y) - end(x))) {
if (end(k) > cursor) continue; // overlaps a later child
self += cursor - end(k);
walk(k, onPath);
cursor = k.start;
}
onPath.set(s.id, self + cursor - s.start);
}
const root = spans.find(s => s.pid === null), onPath = new Map();
walk(root, onPath);
const W = 40, scale = W / root.dur;
for (const s of spans) {
const a = Math.round(s.start * scale), n = Math.max(1, Math.round(s.dur * scale));
const bar = ' '.repeat(a) + (onPath.has(s.id) ? '#' : '=').repeat(n);
console.log(s.name.padEnd(15) + String(s.dur).padStart(4) + ' |' + bar.padEnd(W) + '|');
}
console.log('\ncritical path (self time on the path):');
let sum = 0;
for (const [id, ms] of onPath) { sum += ms; console.log(' ' + spans.find(s => s.id === id).name.padEnd(15) + ms); }
console.log(' total ' + sum + ' ms');# চিহ্নিত বার critical path-এ, = চিহ্নিত বার তার বাইরে। আউটপুটে stock.reserve ২৬০ ms লম্বা হলেও = পেয়েছে, কারণ payment.pay তখনো চলছে। একে শূন্য করলেও মোট ৬৪০ ms-ই থাকবে। Path-এর যোগফল bank.authorize 530, fraud.check 30, payment.pay 30, auth.verify 30, POST /checkout 20 মিলে total 640 ms. কাজ ভাগ করে দ্রুত করার আগে এই হিসাবটি করলে ভুল জায়গায় পরিশ্রম হয় না।
৫ · Instrumentation, OTLP আর Collector
কোডে telemetry তৈরির ব্যবস্থা বসানোকে বলে instrumentation. আমাদের run-এ দুই রকমই ছিল। আটটি span-এর পাঁচটি এসেছে কোড না ছুঁয়ে, আর তিনটি নিজে লিখেছি।
Auto-instrumentation
Auto মানে agent বা wrapper জনপ্রিয় library-কে মুড়ে দেয়। আমাদের ক্ষেত্রে কমান্ড ছিল opentelemetry-instrument gunicorn …. Flask আর requests-এর span, আর header পাঠানো, সব নিজে থেকে হয়েছে। সুবিধা: দ্রুত শুরু। সীমা: সে ব্যবসা বোঝে না, শুধু HTTP আর database দেখে।
Manual instrumentation
Manual মানে আপনি নিজে span লেখেন। fraud.check বা bank.authorize-এর মতো নাম কোনো agent জানে না। Event আর ব্যবসার attribute-ও এখানেই আসে। সুবিধা: অর্থপূর্ণ তথ্য। খরচ: কোড লেখা আর দেখাশোনা।
বাস্তবে নিয়মটি হলো দুটোই। আগে auto দিয়ে পথের কঙ্কাল পান, তারপর গুরুত্বপূর্ণ জায়গায় manual span বসান। Attribute-এর নাম নিজে বানাবেন না, semantic conventions দেখুন। আমাদের run-এ Flask instrumentation দিয়েছে পুরোনো নাম http.status_code, যেখানে নতুন conventions-এ নাম http.response.status_code. সংস্করণ আর সেটিংভেদে নাম বদলায়, তাই dashboard বানানোর আগে নিজের ডেটা দেখে নিন।
OpenTelemetry-র গঠনে API আর SDK আলাদা। Library শুধু API-তে লেখে, যা SDK না থাকলে কিছুই করে না। App-এর মালিক SDK বসিয়ে ঠিক করেন span কোথায় যাবে। Span বের হয় OTLP protocol-এ, সাধারণত gRPC-তে port 4317 বা HTTP-তে 4318. আমাদের service-গুলো সরাসরি Jaeger-এ নয়, পাঠিয়েছে Collector-এ:
App থেকে backend পর্যন্ত
Receiver → Processor → Exporter
- App + SDK
OTLPspan তৈরি - Receiver
otlp :4318গ্রহণ - Processor
memory, scrub, batchসীমা, ছাঁকা, দল - Exporter
Jaegerপাঠানো
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 200
attributes/scrub:
actions:
- key: http.request.header.authorization
action: delete
batch:
timeout: 1s
exporters:
otlp/jaeger:
endpoint: abcl-tr-jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes/scrub, batch]
exporters: [otlp/jaeger]এটি আমাদের run-এর আসল config, যা Docker-এর একান্ত network-এ চলেছে, তাই insecure: true. Production-এ TLS লাগবে। Processor-এর ক্রম গুরুত্বপূর্ণ: memory_limiter আগে, যাতে ভিড়ে Collector নিজে না মরে, আর batch শেষে। এই সংস্করণ চলার সময় সতর্ক করেছে: "otlp" alias is deprecated; use "otlp_grpc" instead. নতুন Collector-এ exporter-এর নাম বদলাতে পারে, তাই AI বা পুরোনো ব্লগের config সরাসরি নেবেন না।
App শুধু কয়েকটি environment variable জানে, যা পর্ব ৪০-এর Compose ফাইলে বসে:
services:
payment-svc:
image: abcl-tr-svc:1
environment:
OTEL_SERVICE_NAME: payment-svc
OTEL_EXPORTER_OTLP_ENDPOINT: http://abcl-tr-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_TRACES_EXPORTER: otlp
OTEL_TRACES_SAMPLER: parentbased_always_onCollector সাধারণত দুই স্তরে চলে। প্রতিটি node বা app-এর পাশে একটি agent (sidecar বা DaemonSet), আর মাঝখানে একটি gateway দল। Agent হালকা কাজ করে, gateway ভারী কাজ যেমন tail sampling করে।
৬ · Sampling: কোন trace রাখবেন
প্রতিটি trace চিরদিন রাখা ব্যয়বহুল। তাই sampling লাগে। প্রশ্ন শুধু কখন সিদ্ধান্ত নেবেন, শুরুতে না শেষে।
ধরুন একটি বাসের কোম্পানি যাত্রীর অভিজ্ঞতা জানতে চায়। Head sampling হলো টিকিট কাটার সময়ই প্রতি দশম যাত্রীকে বেছে নেওয়া। সহজ, কিন্তু কার যাত্রায় দুর্ঘটনা ঘটবে, তখনো জানা নেই। Tail sampling হলো সবার যাত্রা শেষে খাতা দেখে যাঁদের সমস্যা হয়েছে তাঁদের সবাইকে বেছে নেওয়া, সাথে কিছু সাধারণ যাত্রী। ভালো ফল, কিন্তু সবার খাতা শেষ পর্যন্ত ধরে রাখতে হয়।
| দিক | Head sampling | Tail sampling |
|---|---|---|
| সিদ্ধান্ত কখন | root span-এ, trace শুরুতে | পুরো trace জমার পর |
| কে নেয় | SDK. flag header-এ পরের service-এ যায় | Collector |
| Error আর ধীর trace | কোনো পক্ষপাত নেই, হার অনুযায়ী বাদ পড়ে | সব রাখা যায় |
| খরচ | app-এ বাড়তি কাজ কম | Collector-এ memory আর CPU লাগে |
| ঝুঁকি | দুর্লভ সমস্যা হারায় | এক trace-এর সব span এক Collector-এ আসতে হয় |
হিসাব: rate আর হারানোর সম্ভাবনা
Head sampling-এর rate p হলে, N টি error-এর একটিও না ধরার সম্ভাবনা (1 − p)N. ঘণ্টায় ১০টি error আর p = ১% হলে এটি 0.9910 = ০.৯০৪, অর্থাৎ প্রায় ৯০% ঘণ্টায় আপনার হাতে একটিও error trace থাকবে না। p = ১০% হলে 0.910 = ০.৩৪৯।
আমরা আসল stack-এ ১০০০টি request পাঠালাম, যার ১০টিতে ইচ্ছাকৃত ব্যর্থতা আর ১০টিতে ইচ্ছাকৃত ১.২ সেকেন্ড দেরি। প্রথমে head sampling: OTEL_TRACES_, OTEL_TRACES_. তারপর সব trace পাঠিয়ে Collector-এ tail_sampling processor. প্রতিবার Jaeger নতুন করে চালু করা হয়েছে।
# head sampling, ratio 0.1
sent 1000 errors 10 slow 10
traces in Jaeger: 84 | spans: 672 | with error: 1 | over 500 ms: 0
# tail sampling: all errors, all over 500 ms, 5% of the rest
sent 1000 errors 10 slow 10
traces in Jaeger: 64 | spans: 512 | with error: 10 | over 500 ms: 15| পদ্ধতি | জমা trace | জমা span | ১০ error-এর | ১০ ধীর-এর |
|---|---|---|---|---|
| Sampling নেই (হিসাব) | ১০০০ | ৮০০০ | ১০ | ১০ |
| Head ১০% | ৮৪ | ৬৭২ (৮.৪%) | ১ | ০ |
| Tail | ৬৪ | ৫১২ (৬.৪%) | ১০ | ১০ |
Tail কম জমা রেখেও সব সমস্যা ধরেছে। Head-এ ধীর ১০টির একটিও নেই, যা আগের হিসাবের ০.৩৪৯ সম্ভাবনার ঘটনা, অদ্ভুত কিছু নয়।
একটি অপ্রত্যাশিত শিক্ষা। Tail-এর আউটপুটে over 500 ms: 15, অথচ আমরা ধীর করেছিলাম ১০টি, আর client-ও মেপেছে ১০টি। বাকি ৫টি trace-এ দেখা গেল প্রায় ৩০০৪৬ ms, এমনকি ৩০ ms-এর time.sleep-ওয়ালা bank.authorize span দেখাচ্ছে ৩০০১৮.৬ ms. ওই মুহূর্তে container-এর ঘড়ি ৩০ সেকেন্ড লাফ দিয়েছিল: trace-গুলোর শুরুর সময়ে ১৩ থেকে ৪৩ সেকেন্ডের ফাঁক, আর পরে container-এর date host-এর চেয়ে ঠিক ৩০ সেকেন্ড এগিয়ে। এই SDK span-এর সময় নেয় wall clock থেকে, তাই ঘড়ি লাফালে span লম্বা দেখায়। আমাদের keep-slow policy এই ভুয়া ধীর trace-গুলোও রেখে দিয়েছে। এটি clock-সমস্যার আসল উদাহরণ: trace-এর সংখ্যা অদ্ভুত লাগলে আগে ঘড়ি সন্দেহ করুন। হিসাবে বাকি ৩৯টি সাধারণ trace, যা ৯৭৫টির ৪.০%, লক্ষ্য ৫%-এর কাছাকাছি।
এবার সিদ্ধান্তটি ভেতর থেকে দেখি। বাঁয়ে আমাদের আসল policy-র সংক্ষিপ্ত রূপ, ডানে তিনটি trace: A স্বাভাবিক (৫৭.৭ ms), B ব্যর্থ, C ধীর।
1tail_sampling:2 decision_wait: 5s3 policies:4 keep-errors: status ERROR5 keep-slow: over 500 ms6 keep-5pct-of-rest: 5%7exporters: [otlp/jaeger]
| buffer | |
| A 57.7 ms | |
| B 26.8 ms ERROR | |
| C 1226.9 ms |
- ধাপ ১ — জমা। প্রতিটি trace-এ ৮টি span, মোট ২৪টি span Collector-এর memory-তে এলো। Trace id ধরে আলাদা ঘরে থাকছে। এখনো কেউ জানে না কোনটি রাখা হবে।
- ধাপ ২ — অপেক্ষা। ৫ সেকেন্ড পার হলো। ধরে নেওয়া হচ্ছে এতক্ষণে span-গুলো এসে গেছে। এর পরে আসা span দেরিতে আসা হিসেবে আলাদা আচরণ পেতে পারে, তাই
decision_waittrace-এর সবচেয়ে লম্বা সময়ের চেয়ে বেশি রাখতে হয়। - ধাপ ৩ — Error নীতি। B-এর span-এ status ERROR, তাই
keep-errorsB-কে রাখল। B মাত্র ২৬.৮ ms, দ্রুত হলেও রাখা হলো। - ধাপ ৪ — Latency নীতি। C-এর দৈর্ঘ্য ১২২৬.৯ ms, যা ৫০০ ms-এর বেশি, তাই
keep-slowরাখল। - ধাপ ৫ — Probabilistic নীতি। A-এর কোনো সমস্যা নেই। শুধু ৫% সম্ভাবনায় থাকত। এখানে ধরে নিই A বাছাই হয়নি। সব policy দেখার পর কোনো একটি হ্যাঁ বললেই trace থাকে, এখানে কেউ বলেনি।
- ধাপ ৬ — ফল। B আর C-এর ৮ + ৮ = ১৬টি span Jaeger-এ গেল, A-এর ৮টি মুছে গেল। Buffer খালি। মোট ২৪টির ১৬টি রইল, কারণ এই ছোট উদাহরণে ৩টির ২টিই সমস্যার। বাস্তবে এই ভাগ অনেক কম।
এবার বড় সংখ্যায় দেখি। নিচের কোড একই ট্রাফিকে দুটি পদ্ধতি চালায়। সংখ্যা বদলে দেখুন, বিশেষ করে P_ERROR আর RATE.
// Head vs tail sampling on the same simulated traffic.
function mulberry32(seed) {
return function () {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const N = 100000, SPANS = 8; // traces, spans per trace
const P_ERROR = 0.001, P_SLOW = 0.004, RATE = 0.01;
function run(seed) {
const rand = mulberry32(seed);
const head = { kept: 0, err: 0, slow: 0 }, tail = { kept: 0, err: 0, slow: 0 };
let errors = 0, slows = 0;
for (let i = 0; i < N; i++) {
const x = rand();
const isErr = x < P_ERROR, isSlow = !isErr && x < P_ERROR + P_SLOW;
errors += isErr; slows += isSlow;
// Head: decide at the start, knowing nothing about the outcome.
if (rand() < RATE) { head.kept++; head.err += isErr; head.slow += isSlow; }
// Tail: decide at the end, when the outcome is known.
if (isErr || isSlow || rand() < RATE) {
tail.kept++; tail.err += isErr; tail.slow += isSlow;
}
}
console.log(`seed ${seed}: ${errors} errors, ${slows} slow in ${N} traces`);
console.log(` head 1%: kept ${head.kept}, errors ${head.err}, slow ${head.slow}`);
console.log(` tail : kept ${tail.kept}, errors ${tail.err}, slow ${tail.slow}`);
}
[1, 2, 3].forEach(run);
console.log(`\nno sampling stores ${N * SPANS} spans`);Seed 1-এ 103 errors, 414 slow in 100000 traces. head 1%: kept 943, errors 0, slow 2, আর tail: kept 1517, errors 103, slow 414. Head-এ একটি error-ও নেই! Seed 2 ও 3-এ head পেয়েছে ২টি ও ১টি error. Tail জমা রেখেছে প্রায় ১.৫ গুণ, কিন্তু সব error আর সব ধীর trace ধরেছে। Head-এর লাভ একটিই: Collector-এ কোনো buffer লাগে না, আর বাদ পড়া span তৈরিরও খরচ কম।
একটি ফাঁদ। Tail sampling-এ এক trace-এর সব span একই Collector-এ পৌঁছাতে হয়। তিনটি Collector সাধারণ load balancing-এর পেছনে রাখলে প্রত্যেকে trace-এর টুকরো পাবে, আর error-এর সিদ্ধান্ত ভুল হবে। সমাধান দুই স্তর: প্রথম স্তর trace id ধরে route করে (Collector-এর loadbalancing exporter), দ্বিতীয় স্তর sampling করে। আর parentbased_* sampler মানে পরের service আগের সিদ্ধান্ত মেনে চলে, তাই কোনো trace আধখানা হয় না।
৭ · Metric, log আর trace জোড়া লাগানো
তিনটি সংকেত আলাদা থাকলে আপনি তিনটি tool-এ হাতড়ে বেড়াবেন। জোড়ার সূত্র হলো একটিই: trace id. কাজের ক্রম সাধারণত এমন: metric-এ alert বাজল, সেখান থেকে একটি উদাহরণ trace, trace থেকে ওই request-এর log.
Metric থেকে trace: exemplar. Histogram-এর একটি bucket-এর সাথে একটি উদাহরণ trace id জুড়ে রাখা যায়। Grafana-র মতো tool-এ p99 গ্রাফের একটি বিন্দু চাপলে সরাসরি ওই trace খোলে। OpenMetrics ফরম্যাটে লাইনটি এমন দেখায় (নমুনা আউটপুট, এই run-এ metric চালানো হয়নি, version-ভেদে আলাদা হতে পারে):
http_server_duration_seconds_bucket{le="2.5"} 1017 # {trace_id="42a322d43ae0d3f1b5f444ced406e897"} 1.2269 1793394784.0#-এর আগে সাধারণ histogram-এর লাইন, পরে exemplar: কোন trace, তার মান আর সময়। Prometheus-এ exemplar রাখার সুবিধা সংস্করণভেদে আলাদাভাবে চালু করতে হতে পারে। মনে রাখবেন, sampling-এ বাদ পড়া trace id দিলে সেই লিংক কানা হবে। তাই exemplar sampled trace থেকে নেওয়া ভালো।
Log থেকে trace. আমাদের app প্রতিটি structured log লাইনে trace_id লিখেছে। এটি পুরোনো correlation id-এর আধুনিক রূপ। নিচের log আমাদের run থেকে নেওয়া, শুধু লাইন ছোট করতে span_id বাদ। একটি error লাইন থেকে পুরো request খুঁজে আনি:
set -euo pipefail
cat > app.log <<'LOG'
{"svc":"shop-api","msg":"checkout received","trace_id":"42a322d43ae0d3f1b5f444ced406e897"}
{"svc":"order-svc","msg":"order received","trace_id":"42a322d43ae0d3f1b5f444ced406e897"}
{"svc":"shop-api","msg":"checkout received","trace_id":"fc01af965cfa8453d8b537cf6fa9b563"}
{"svc":"order-svc","msg":"order received","trace_id":"fc01af965cfa8453d8b537cf6fa9b563"}
{"svc":"payment-svc","msg":"payment received","trace_id":"42a322d43ae0d3f1b5f444ced406e897"}
{"svc":"payment-svc","msg":"payment received","trace_id":"fc01af965cfa8453d8b537cf6fa9b563"}
{"svc":"payment-svc","msg":"bank refused","trace_id":"fc01af965cfa8453d8b537cf6fa9b563","level":"error"}
LOG
# 1. An alert sends you to the error line. Take its trace id.
id=$(grep '"level":"error"' app.log | sed 's/.*"trace_id":"\([0-9a-f]*\)".*/\1/')
echo "trace id: $id"
# 2. Pull every line of that one request, from every service.
grep "$id" app.log | sed -E 's/.*"svc":"([^"]*)","msg":"([^"]*)".*/\1: \2/'
# 3. How much noise did that remove?
echo "all lines: $(wc -l < app.log), this request: $(grep -c "$id" app.log)"আউটপুট শুরু হয় trace id: fc01af965cfa8453d8b537cf6fa9b563 দিয়ে। তারপর চারটি লাইন ক্রমে: shop-api: checkout received, order-svc: order received, payment-svc: payment received, payment-svc: bank refused. শেষে all lines: 7, this request: 4. দুটি request-এর লাইন মিশে ছিল, trace id তাদের আলাদা করল। Centralized log system-এ (পর্ব ৬৪) একই কাজ একটি query-তে হয়, আর Jaeger-এ ওই id দিলে waterfall খোলে।
৮ · Queue, async কাজ আর PII
Queue-র ওপারে trace
HTTP-তে caller অপেক্ষা করে, তাই child span parent-এর ভেতরে থাকে। Message queue-তে তা নয়। Producer বার্তা রেখে চলে যায়, consumer হয়তো ৩ মিনিট পরে ধরে। ধারণাটি একই, শুধু header-এর জায়গায় context যায় বার্তার metadata-য়। Span kind হয় producer আর consumer. Queue-তে বসে থাকার সময় (queue time) waterfall-এ ফাঁক হিসেবে দেখা যায়, যা নিজেই একটি বড় রোগ-নির্ণয়ের তথ্য।
Consumer একসাথে ৫০টি বার্তার batch নিলে সমস্যা হয়: একটি span-এর parent তো ৫০টি হতে পারে না। এখানে কাজে লাগে span link. Consumer নিজের trace শুরু করে, আর ৫০টি উৎসের দিকে link রাখে। কিছু দল এক বার্তার ক্ষেত্রেও parent-এর বদলে link নেয়, কারণ ঘণ্টা পরে চলা কাজ একই trace-এ থাকলে waterfall অর্থহীন লম্বা হয়।
# Sample, not run here. Link back to the producer instead of making it parent.
ctx = extract(message.headers) # read traceparent from the message
source = trace.get_current_span(ctx).get_span_context()
with tracer.start_as_current_span("email.send",
kind=SpanKind.CONSUMER,
links=[trace.Link(source)]):
send_email(message.body)Queue আর event-এর নকশা বিস্তারে আসবে পর্ব ৭০-এ।
Span-এ কী রাখবেন না
Trace-এ অনেক সময় log-এর চেয়েও বেশি তথ্য থাকে, আর অনেক দল সেটি log-এর মতো পাহারা দেয় না। URL-এর query string, database query, request header, exception-এর message, সবখানে PII ঢুকে যেতে পারে। একটি span-এর attribute-এ ক্রেতার ফোন নম্বর বসলে সেটি Collector, backend আর হয়তো অন্য দেশের vendor পর্যন্ত যায়। দেশভেদে ব্যক্তিগত তথ্যের আইন আলাদা, তাই আপনার ক্ষেত্রে কী প্রযোজ্য, আইনজ্ঞের সাথে যাচাই করুন।
- না: password, token,
Authorizationheader, card নম্বর, OTP, NID. - সতর্ক: ফোন, email, নাম, ঠিকানা, পূর্ণ URL, SQL-এর মান।
- ঠিক আছে: route (
/orders/{id}), status code, পেমেন্টের ধরন, সংস্করণ, region.
দুই স্তরে পাহারা দিন। কোডে শুরুতেই না লেখা হলো সবচেয়ে ভালো। আর শেষ জালের মতো Collector-এ attributes/scrub processor, যেমন আমাদের config-এ ছিল। Baggage আরও ঝুঁকির, কারণ এটি header হয়ে বাইরের service-এও চলে যেতে পারে। Trace দেখার অনুমতিও least privilege মেনে দিন, আর retention ছোট রাখুন।
৯ · খরচ আর cloud-এর সেবা
পরীক্ষায় আসার মতো একটি হিসাব করি। ধরে নিই একটি API-তে সেকেন্ডে ২০০ request, প্রতি request-এ ৮টি span, প্রতি span গড়ে ১ KB, আর backend-এ ingest-এর দাম GB-প্রতি $০.৩০. এই দাম শুধু হিসাবের জন্য ধরা। আসল দাম provider-এর pricing page থেকে দেখবেন। আমরা ৩০ দিনের মাস ধরি, আর 1 GB = 109 B.
| ধাপ | হিসাব | ফল |
|---|---|---|
| প্রতি সেকেন্ডে span | 200 × 8 | 1,600 |
| প্রতি দিনে span | 1,600 × 86,400 | 138,240,000 |
| প্রতি মাসে ডেটা | 138.24 GB × 30 | 4,147.2 GB |
| সব রাখলে মাসিক | 4,147.2 × 0.30 | $1,244.16 |
| Head ১% | 1,244.16 × 0.01 | $12.44 |
| Tail: ০.৫% error, ১% ধীর, বাকির ১% | 0.005 + 0.01 + 0.985 × 0.01 = 2.485% | $30.92 |
Tail-এ বাড়তি লাগে Collector-এর memory: decision_wait ১০ সেকেন্ড হলে 1,600 × 10 × 1 KB = ১৬ MB শুধু কাঁচা span-এর জন্য। আসল memory এর কয়েক গুণ হয়, আর ভিড়ের সময়ের জন্য হিসাব করতে হয়। খরচ বাড়ায় বেশি attribute আর exception-এর লম্বা stack trace, এমনকি একটি লুপের ভেতরের span-ও। হাজার বার চলা লুপে span নয়, একটি event বা attribute ভালো।
Cloud provider-এর tracing সেবা
ধারণা একই, নাম আলাদা। Vendor-নিরপেক্ষ পথ হলো কোড OpenTelemetry-তে রাখা, আর Collector থেকে যেখানে দরকার পাঠানো। লেখার সময়ে সবগুলোই কোনো না কোনোভাবে OpenTelemetry-র ডেটা নিতে পারে, তবে পথ ও সীমা সেবা ও সময়ভেদে আলাদা।
| Provider | সেবা | খেয়াল রাখার মতো |
|---|---|---|
| AWS | X-Ray / CloudWatch-এর tracing | নিজস্ব X-Amzn-Trace-Id header আছে, W3C-র সাথে মেলানো যাচাই করুন |
| Azure | Application Insights (Azure Monitor) | OpenTelemetry-ভিত্তিক distro আছে, connection string লাগে |
| Google Cloud | Cloud Trace | Sampling আর ingest-এর কোটা দেখে নিন |
হাতে-কলমে করতে চাইলে নিজের machine-এ Jaeger আর Collector container-ই যথেষ্ট, cloud account লাগে না। Cloud-এর সেবায় পরীক্ষা করলে টাকা কাটতে পারে, তাই budget alert রাখুন আর শেষে resource মুছুন। আমাদের run শেষে containers, network আর image নাম ধরে মুছে ফেলা হয়েছে।
আরও গভীরে: Dapper paper-এ বেশি ট্রাফিকের service-এ ১/১০২৪ হারের sampling-এর কথা আছে, আর paper-এর যুক্তি ছিল উচ্চ ট্রাফিকে সাধারণ ঘটনা এমনিতেই বারবার ধরা পড়ে। traceidratio sampler trace id-র ওপর নির্ভর করে, তাই একই rate-এ সব service একই সিদ্ধান্তে পৌঁছাতে পারে। Jaeger v2 নিজেই OpenTelemetry Collector-এর ওপর গড়া, তাই তার log-এ Collector-এর মতো লাইন দেখা যায়। Tail sampling-এ দেরিতে আসা span-এর আচরণ আর সিদ্ধান্তের cache সংস্করণভেদে বদলায়, তাই release note পড়ে নিন।
পরীক্ষা ও ইন্টারভিউতে:
- Trace আর span-এর পার্থক্য? Trace পুরো যাত্রা, span তার একটি কাজের টুকরো।
traceparent-এর চারটি অংশ? version, ৩২ hex trace-id, ১৬ hex parent-id, ২ hex flags.- Head আর tail sampling? Head শুরুতে ফল না জেনে, tail শেষে ফল দেখে সিদ্ধান্ত নেয়।
- Collector কেন? App-কে backend থেকে আলাদা রাখা, ছাঁকা, batch আর একাধিক গন্তব্য।
- Exemplar কী? Metric-এর মানের সাথে জোড়া একটি উদাহরণ trace id.
১০ · AI-এর যুগে tracing
AI খুব দ্রুত OpenTelemetry-র config লিখে দেয়। কিন্তু এই জগৎ দ্রুত বদলায়, আর পুরোনো উদাহরণ ইন্টারনেটে ভরা। নিচের ভুলগুলো প্রায়ই দেখা যায়:
| AI-এর ভুল | ফল | যাচাই |
|---|---|---|
পুরোনো jaeger exporter বা OpenTracing library | Collector চালু হয় না, অথবা বাতিল কোড | OTLP ব্যবহার, চলতি docs পড়া |
| সব header বা SQL-এর মান attribute-এ তোলা | token আর PII backend-এ জমা | একটি আসল span খুলে দেখা |
Production-এ always_on, বা error না ভেবে head ১% | বিল ফুলে ওঠে, অথবা error হারায় | rate-এর হিসাব করা |
| Tail sampling-এ একাধিক Collector, trace id routing নেই | ভাঙা trace, ভুল সিদ্ধান্ত | একটি trace-এর span সংখ্যা গোনা |
Collector-এর 4317 port 0.0.0.0/0-তে খোলা, TLS নেই | যে কেউ ডেটা পাঠাতে পারে | security group দেখা (পর্ব ২৭) |
latest image tag, বানানো flag বা setting | হঠাৎ বদল, চালু না হওয়া | version pin, --dry-run, throwaway পরিবেশে চালানো |
AI-কে কাজ দেওয়ার সময় সংস্করণ, ভাষা আর সীমা বলে দিন। প্রম্পটে কখনো আসল token, trace-এর আসল ডেটা বা ক্রেতার তথ্য দেবেন না। ফলাফল যাচাইয়ের পথও চেয়ে নিন:
AI-এর দেওয়া tracing সেটআপের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Dapper হাজারে একটি trace রেখেই কাজ চালিয়েছিল। তাহলে আজ আমাদের tail sampling লাগে কেন?
২০১০ সালের paper-এ Google জানায়, তাদের বেশি ট্রাফিকের service-এ ১/১০২৪ হারের sampling-ও উপকারী তথ্য দিয়েছে। যুক্তিটি সহজ। সেকেন্ডে লক্ষ request হলে হাজারে একটি রাখলেও কোনো সমস্যা ঘনঘন ঘটলে তা বারবার ধরা পড়ে। আর যে সমস্যা খুব কম লোকের গায়ে লাগে, তখনকার লক্ষ্যে সেটি অগ্রাধিকার পেত না। সে যুগে দলগুলোর আসল প্রশ্ন ছিল সার্বিক latency নিয়ে, একটি নির্দিষ্ট request নিয়ে নয়। Dapper-এর লক্ষ্য ছিল খুব কম বাড়তি খরচে সব জায়গায় চালু থাকা। Head sampling এই লক্ষ্যে নিখুঁত, কারণ বাদ পড়া trace-এ প্রায় কোনো কাজ হয় না।
কিন্তু আমাদের বেশিরভাগের অবস্থা আলাদা। একটি মাঝারি অ্যাপে সেকেন্ডে কয়েকশো request, আর আসল সমস্যা হলো দুর্লভ error, যা হয়তো দিনে ২০ বার ঘটে। ১% হারে দিনে ০.২টি error trace পাবেন। অর্থাৎ পাঁচ দিনে গড়ে একটি। আরেকটি বদল হলো মানুষের প্রত্যাশা। আজ on-call প্রকৌশলী নির্দিষ্ট একটি ব্যর্থ request-এর trace খোলেন। ট্রাফিক বড় না হলে সেটি পরিসংখ্যানের প্রশ্ন নয়, একটি নির্দিষ্ট ঘটনার প্রশ্ন।
আমাদের run-এ ১০% হারে ১০টি ধীর request-এর একটিও ধরা পড়েনি। Tail sampling কম span রেখেই সবগুলো ধরেছে। তবে দাম আছে: Collector-কে সব span memory-তে ধরে রাখতে হয়, trace id ধরে route করতে হয়, আর ভিড়ের সময় এই স্তরই প্রথম চাপে পড়ে। তাই অনেকে দুটো মেশান: খুব বেশি ট্রাফিকের সাধারণ পথে সামান্য head sampling, তারপর tail.
মূল উপলব্ধি: Sampling কৌশল নির্ভর করে ট্রাফিক আর প্রশ্নের ওপর। “সার্বিক ছবি চাই” হলে head যথেষ্ট, “এই ব্যর্থ request-টি চাই” হলে ফল দেখে রাখতে হবে।
প্র ০২OpenTracing আর OpenCensus দুটোই ভালো প্রকল্প ছিল। মিশে যেতে হলো কেন, আর এর থেকে মান তৈরির কী শিক্ষা?
২০১৬ সালের দিকে সমস্যাটি ছিল এমন: প্রতিটি tracing vendor-এর নিজের agent আর library. একটি open-source library-র লেখক কোন vendor-এর জন্য instrument করবেন? OpenTracing উত্তর দিল একটি নিরপেক্ষ API দিয়ে। কিন্তু এতে শুধু API ছিল, implementation নয়, আর metric ছিল না। ওদিকে Google থেকে আসা OpenCensus দিল library, trace আর metric একসাথে, সাথে একটি Collector-এর ধারণা।
দুটোই ভালো, কিন্তু library-র লেখককে এবার দুটির একটি বেছে নিতে হচ্ছিল। ব্যবহারকারীও দ্বিধায় পড়ছিলেন, কোন পথে গেলে কয়েক বছর পরে কোড আবার লিখতে হবে না। মান তৈরির চেষ্টাই নতুন বিভক্তি আনল। ২০১৯ সালে দুই দল ঘোষণা করল তারা মিশে OpenTelemetry হবে, পুরোনো ব্যবহারকারীদের জন্য সেতুর ব্যবস্থাও থাকবে। দুটি প্রকল্প থেকেই সেরা ধারণা এলো: API আর SDK-র আলাদা থাকা, আর Collector.
ফল হলো একটি বড় বদল। আগে vendor প্রতিযোগিতা করত agent দিয়ে, যা বদলানো কঠিন। এখন instrumentation হয়ে গেছে সাধারণ সম্পত্তি, আর vendor প্রতিযোগিতা করে backend-এর গুণে। এতে vendor lock-in কমে। তবে পুরোপুরি যায় না, কারণ dashboard, alert আর query তখনো backend-নির্ভর। Jaeger নিজেও v2-তে Collector-এর ওপর গড়া হয়েছে, আর পুরোনো Jaeger client library-র জায়গা নিয়েছে OpenTelemetry SDK.
দামও আছে। এত বড় প্রকল্পে signal আর ভাষাভেদে পরিপক্বতা আলাদা, attribute-এর নাম বদলায়, আমাদের run-এই একটি alias deprecated দেখা গেল।
মূল উপলব্ধি: M টি library আর N টি backend থাকলে মাঝে একটি সাধারণ ভাষা M×N সমস্যাকে M+N বানায়। কিন্তু মান তখনই সফল, যখন প্রতিদ্বন্দ্বীরাও তাতে যোগ দেয়।
প্র ০৩Trace-এর সময়কে কতটা বিশ্বাস করবেন? আলাদা machine-এর ঘড়ি মিলিয়ে waterfall আঁকা কি আদৌ ঠিক?
Span-এর শুরু আর শেষের সময় লেখে যে machine-এ span চলছে, সেই machine-এর ঘড়ি। Backend-এ এসে আলাদা machine-এর সময় একই অক্ষে বসে। ঘড়িতে পার্থক্য থাকলে কী হয়? NTP-তে মেলানো ঘড়িও সাধারণত কয়েক ms এদিক-ওদিক থাকে। ফলে দেখা যেতে পারে child span parent-এর আগে শুরু হয়েছে, যা শারীরিকভাবে অসম্ভব। এটিই clock skew. Dapper paper-ও এই সমস্যার কথা বলে, আর জানায় RPC-তে client আগে পাঠায়, server পরে পায়, এই ক্রম ব্যবহার করে পার্থক্যের একটি সীমা পাওয়া যায়। Jaeger-এর মতো tool-ও দেখানোর সময় skew ঠিক করার চেষ্টা করে।
আমাদের run-এ এর চরম রূপ দেখা গেল। Container-এর ঘড়ি হঠাৎ ৩০ সেকেন্ড লাফ দিল। ৫টি request তখন চলছিল। Client মেপেছে সব ৫০০ ms-এর নিচে, কিন্তু trace বলল ৩০ সেকেন্ড। ভুলটি এখানে যে সময়ের দৈর্ঘ্য মাপা হয়েছে wall clock দিয়ে। দৈর্ঘ্য মাপার জন্য ভালো হলো monotonic clock, যা পেছায় না বা লাফায় না। Google-এর Spanner অন্য পথে গেছে: GPS আর পারমাণবিক ঘড়ি দিয়ে সময়ের অনিশ্চয়তা সীমিত করেছে, কিন্তু সাধারণ system-এ সে সুবিধা নেই।
বাস্তব নিয়ম কী? একই span-এর দৈর্ঘ্য সাধারণত বিশ্বাসযোগ্য, যদি ঘড়ি না লাফায়। এক service-এর ভেতরের ক্রমও বিশ্বাসযোগ্য। কিন্তু দুই machine-এর মধ্যে কয়েক ms-এর পার্থক্য দেখে “network ধীর” বলে সিদ্ধান্ত নেওয়া বিপজ্জনক। Parent-child সম্পর্ক সময় থেকে নয়, id থেকে আসে, তাই সেটি নির্ভরযোগ্য।
মূল উপলব্ধি: Trace-এর কাঠামো সত্য, সময় হলো পরিমাপ। আর পরিমাপ যন্ত্রেরও ভুল হয়। সন্দেহজনক ফল এলে স্বাধীন আরেকটি মাপ, যেমন client-এর সময়, দিয়ে মিলিয়ে নিন।
প্র ০৪ছোট দলের একটি monolith অ্যাপে কি distributed tracing বসানো উচিত? কখন এটি বাড়তি বোঝা?
Tracing-এর লাভ আসে যখন একটি request অনেক জায়গা ঘোরে, আর ধারণা করা কঠিন হয় সময় কোথায় যাচ্ছে। একটি monolith আর একটি database হলে slow query log, profiler আর ভালো log-ই অনেক কিছু বলে দেয়। তাহলে কি tracing অর্থহীন? পুরোপুরি নয়। একটি monolith-ও বাইরের payment gateway, cache, email service ডাকে। সেখানে auto-instrumentation দিয়ে সস্তায় একটি একক process-এর waterfall পাওয়া যায়, যা দেখায় একটি request-এর কতটা database-এ, কতটা বাইরের ডাকে গেল।
তবে পুরো ব্যবস্থার দাম হিসাব করুন। Collector চালানো, backend রাখা, sampling ঠিক করা, retention, PII পাহারা, দলের শেখা। পাঁচজনের দলে একজনকে এই কাজে আটকে রাখা ব্যয়বহুল হতে পারে। একটি মাঝামাঝি পথ হলো আগে trace_id log-এ বসানো, আর W3C header পাঠানো শুরু করা। এতে পরে backend যোগ করলেই সব জোড়া লেগে যায়, কোড আবার বদলাতে হয় না। Sampling কম রেখে বা managed সেবার ছোট পরিকল্পনা দিয়ে শুরু করা যায়।
একটি ভুল প্রেরণাও আছে: “বড় কোম্পানি করে, তাই আমরাও করব।” Dapper এসেছিল Google-এর আকারের সমস্যা থেকে। আপনার সমস্যা ভিন্ন হলে সমাধানও ভিন্ন হবে। প্রশ্ন করুন: গত তিন মাসে কোন incident-এ tracing থাকলে সময় বাঁচত? উত্তর না থাকলে অপেক্ষা করা যায়। তবে service সংখ্যা বাড়তে শুরু করলে দেরি করবেন না, কারণ পরে সব service-এ বসানো অনেক কঠিন।
মূল উপলব্ধি: Instrumentation সস্তায় আগে বসান, ভারী ব্যবস্থা প্রয়োজন এলে যোগ করুন। Trace id log-এ রাখাটাই সবচেয়ে কম খরচের প্রথম ধাপ।
প্র ০৫একটি trace কোথায় শেষ হয়? ঘণ্টা ধরে চলা কাজ বা queue থাকলে “একটি request” ধারণাটি টেকে কি?
Dapper-এর জগৎ ছিল মূলত synchronous RPC: একটি request এলো, গাছ ছড়াল, উত্তর ফিরল। সেখানে trace-এর শুরু আর শেষ পরিষ্কার। আধুনিক cloud অ্যাপে তা আর এত সরল নয়। ক্রেতা অর্ডার দিলেন, API ২০০ ফেরত দিল, তারপর queue-তে বার্তা গেল। ৩ মিনিট পরে invoice তৈরি, ১ ঘণ্টা পরে email, ২ দিন পরে ডেলিভারির আপডেট। এটি কি একটি trace?
একটি trace ধরলে waterfall ২ দিন লম্বা হয়, বেশিরভাগ অংশ ফাঁকা, আর tail sampling-এর decision_wait এত দীর্ঘ রাখা অসম্ভব। Span-এর মোট সময় অর্থ হারায়। আবার পুরো আলাদা trace ধরলে সম্পর্ক হারিয়ে যায়। মাঝের পথ হলো span link: প্রতিটি ধাপ নিজের trace, কিন্তু আগের ধাপের দিকে একটি link. Backend link ধরে পথ দেখাতে পারে। Fan-out-এ যেখানে এক বার্তা অনেক consumer পায়, বা batch-এ যেখানে অনেক বার্তা এক কাজে মেশে, সেখানে parent-child গাছ হয় না, link-ই স্বাভাবিক। ব্যবসার দীর্ঘ কাজের জন্য আরেকটি পথ হলো workflow engine-এর নিজস্ব history, যা ধাপগুলো মনে রাখে (পর্ব ৭০)। সেখানে trace শুধু প্রতিটি ধাপের ভেতরের ছবি দেখায়।
Dead-letter বা retry-র ক্ষেত্রেও একই কথা। Retry একই trace-এ রাখলে কতবার চেষ্টা হলো দেখা যায়, কিন্তু sampling-এর সিদ্ধান্ত আগের মতোই থাকে। তাই দলের ভেতরে একটি নিয়ম ঠিক করে নেওয়া দরকার, কোথায় trace ভাঙবেন আর কোথায় link দেবেন।
মূল উপলব্ধি: Trace একটি মডেল, আর প্রতিটি মডেলের সীমা আছে। অনুরোধ-উত্তরের কাজে গাছ মানায়, দীর্ঘ কাজে link আর workflow history মানায়।
অনুশীলন
খাতা-কলম নিয়ে বসুন। আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।
-
হিসাব: একটি service-এ দিনে ২০,০০,০০০ trace, যার ০.২% error. (ক) ৫% head sampling-এ দিনে কতটি trace জমবে, আর কতটি error trace ধরা পড়বে বলে আশা করা যায়? (খ) “সব error, বাকির ১%” tail sampling-এ কতটি জমবে?
মোট error = 2,000,000 × 0.002 = ৪,০০০।
(ক) জমবে 2,000,000 × 0.05 = ১,০০,০০০ trace. আশা করা error trace 4,000 × 0.05 = ২০০।
(খ) সব error ৪,০০০টি, বাকি 1,996,000 × 0.01 = ১৯,৯৬০টি। মোট ২৩,৯৬০ trace. Head-এর চেয়ে চার ভাগের এক ভাগেরও কম জমা, অথচ error ২০ গুণ।
-
আউটপুট অনুমান:
parentbased_traceidratiosampler-ওয়ালা একটি service-এ request এলোtraceparent: 00-নিয়ে। (ক) এই service-এর span কি Jaeger-এ যাবে? (খ) পরের service-এ যাওয়া header-এর trace-id আর flags কী হবে? (গ) এই service-এর log-এc8351ad671afbc3e94d7ca250e0da4ae- c8b92d09406496cd- 02 trace_idলেখা থাকলে সেটি দিয়ে Jaeger-এ খুঁজলে কী পাবেন?(ক) না।
02-এর bit 0 বন্ধ, আর parent-based sampler parent-এর সিদ্ধান্ত মানে।(খ) Trace-id একই
c8351ad6…a4ae, flags02, শুধু parent-id নতুন। Context বন্ধ হয় না, শুধু রেকর্ড হয় না। আমাদের head run-এরorder-svc-এর log-এ ঠিক এই ধরনের লাইন ছিল।(গ) কিছুই না। Log-এ id আছে, কিন্তু trace জমা হয়নি। এই কারণেই log থেকে trace-এ যাওয়ার লিংক সব সময় কাজ করে না।
-
নকশা ও সমালোচনা: একজন সহকর্মী প্রস্তাব দিলেন: “Production-এ সব span রাখি। প্রতিটি span-এ
user.phone, পূর্ণ URL আর SQL বসাই, debug সহজ হবে। তিনটি Collector সাধারণ load balancer-এর পেছনে রেখে tail sampling করি, আর 4317 port ইন্টারনেটে খুলে দিই, যাতে mobile app সরাসরি পাঠাতে পারে।” কী কী সমস্যা, আর আপনি কী বদলাবেন?- সব span: খরচ অনেক। Tail sampling-এর সাথে এটি পরস্পরবিরোধীও। Error, ধীর আর অল্প শতাংশ রাখুন।
- PII: ফোন নম্বর, URL-এর query, SQL-এর মান সরান। দরকার হলে একমুখী hash বা
/orders/{id}-এর মতো route. Collector-এ scrub processor রাখুন। - Collector: সাধারণ load balancer-এ trace-এর span ছড়িয়ে যাবে। Trace id ধরে route করা একটি স্তর লাগবে।
- Port: ইন্টারনেটে খোলা ingest ঠিক নয়। Mobile-এর জন্য authentication, TLS আর rate limit-ওয়ালা আলাদা endpoint লাগবে। ভেতরের Collector ভেতরেই থাকবে।
-
Waterfall পড়া:
checkoutচলে ০ থেকে ৫০০ ms. তার ভেতরেa১০ থেকে ১১০, এরপর একসাথেb১২০ থেকে ২২০ আরc১২০ থেকে ৪৮০. (ক) Critical path-এ কোন span? (খ)checkout-এর self time কত? (গ)b-কে অর্ধেক করলে মোট সময় কত কমবে? (ঘ)c-কে ১০০ ms কমালে?(ক)
checkout,a,c. শেষ থেকে হাঁটলে ৪৮০-তেcশেষ হচ্ছে,bতার ভেতরেই ঢাকা।(খ) Path ধরে: ০–১০ (১০), ১১০–১২০ (১০), ৪৮০–৫০০ (২০), মোট ৪০ ms.
(গ) শূন্য, কারণ
bcritical path-এর বাইরে।(ঘ) ১০০ ms, অর্থাৎ মোট ৪০০ ms, কারণ
b২২০-তেই শেষ, তাইc-র ৩৮০-তে শেষ হওয়াই ঠিক করে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি trace-এর যাত্রা সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- প্রথম service-এ root span আর trace id তৈরি
traceparentheader-এ context পরের service-এ পাঠানো- পরের service header পড়ে child span তৈরি করা
- SDK span-গুলো OTLP দিয়ে Collector-এ পাঠানো
- Collector-এর processor ছেঁকে backend-এ পাঠানো
- Backend trace id ধরে জুড়ে waterfall দেখানো
traceparent: 00- header-এ এক service থেকে পরের service-এ যাওয়ার সময় কোন অংশ অবশ্যই বদলায়?
- trace-id
- parent-id
- version
- flags, সবসময়
একটি trace-এ POST /checkout span ১০০০ ms, তার একমাত্র child pay ৯৮০ ms, আর pay-এর একমাত্র child bank ৯৭০ ms. সময় কোথায় গেছে?
- POST /checkout-এ
- pay-এ
- bank-এ
- তিনটিতে সমানভাবে
৫% head sampling-এ দিনে ২০টি দুর্লভ error ঘটে। Error trace ধরা নিয়ে কোনটি ঠিক?
- Error trace সবসময় রাখা হয়, কারণ SDK error দেখে
- Error trace কখনোই রাখা হয় না
- বাদ পড়া error trace পরে Collector ফিরিয়ে আনে
- গড়ে দিনে ১টি ধরা পড়ে, আর কোনো কোনো দিন একটিও নয়
তিনটি Collector সাধারণ round-robin load balancer-এর পেছনে রেখে tail sampling চালালে প্রধান সমস্যা কী?
- এক trace-এর span ছড়িয়ে যায়, তাই সিদ্ধান্ত ভুল হয়
- OTLP load balancer পার হতে পারে না
- Collector একই trace id দুবার বানায়
- Header-এর flags মুছে যায়
Metric-এর p99 গ্রাফের একটি বিন্দু থেকে সরাসরি নির্দিষ্ট একটি trace-এ যাওয়ার সুবিধা কোন জিনিস দেয়?
- Baggage
- Exemplar
- tracestate
- Span link
একটি consumer একসাথে ৫০টি বার্তার batch প্রক্রিয়া করে, আর প্রতিটি বার্তা আলাদা trace থেকে এসেছে। Trace-এ সম্পর্ক রাখার সবচেয়ে উপযুক্ত উপায় কোনটি?
- একটি span-এর ৫০টি parent বসানো
- সব context ফেলে দিয়ে নতুন trace, কোনো সম্পর্ক ছাড়া
- Consumer span-এ ৫০টি span link রাখা
- সব producer-কে একই trace id দেওয়া
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Metric বলে কতটা, log বলে কী, trace বলে এই request-এর সময় কোথায় গেল।
- Waterfall-এ লম্বা বার নয়, self time আর critical path খুঁজুন।
traceparent: version, trace-id স্থির, parent-id প্রতি hop-এ নতুন, flags-এর bit 0 sampled.- কোড OpenTelemetry-তে, পাঠানো OTLP-তে, মাঝে Collector. Backend বদলানো যায়।
- Head সস্তা কিন্তু দুর্লভ error হারায়, tail ধরে কিন্তু memory আর routing লাগে। PII কখনো span-এ নয়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৬৬ · Alerting আর SLO — burn rate, on-call আর ভালো alertপরবর্তী পর্বদেখতে শিখলেন; এবার কখন ঘুম ভাঙাবেন, আর কখন ভাঙাবেন না।
- ক্লাউড কম্পিউটিং ও DevOps · কোর্স হোম৮১টি পর্বপুরো কোর্সের রোডম্যাপ। পরের পর্ব ৬৬: Alerting আর SLO.
- পর্ব ৪০ · Docker Composeআগের পর্বএই পর্বের স্থানীয় stack যে ধরনের ফাইলে চলেছে।
- পর্ব ৫৯ · Deployment strategyআগের পর্বCanary-র সময় trace দেখে নতুন সংস্করণ বিচার করা।
- System Design · Observabilityগভীরে যানতিন সংকেতের তত্ত্ব আর নকশার দৃষ্টিভঙ্গি।
- System Design · Microservicesপাশের ট্র্যাকService ভাগ করার নকশা, যা tracing-কে দরকারি করে।