Foundationপ্রথম নীতি থেকে
LEVEL 12লেসন ২০/২২কঠিন৫০ মিনিট

Observability — Metrics, Logs, আর Trace কীভাবে একসাথে কাজ করে

Observability: How Metrics, Logs, and Traces Compose

Metrics সংখ্যা বলে, logs ঘটনা বলে, trace ইতিমধ্যে distributed-tracing লেসনে path বলে দিয়েছে — আজ দেখব এই তিন pillar কীভাবে একটা containerized platform-এ sidecar/collector দিয়ে জড়ো হয়ে, shared trace_id দিয়ে জোড়া লেগে, একটা সম্পূর্ণ observability picture বানায়; আর কেন Kubernetes-এর disposable Pod-এর নাম metric label হিসেবে ব্যবহার করলে backend চুপচাপ ভেঙে পড়তে পারে — বাস্তব হিসাব-সহ।

এই লেসন শেষে আপনি পারবেন

  • Metrics, logs, আর trace — এই তিন pillar-কে তাদের DATA SHAPE (aggregatable numeric time series বনাম discrete timestamped event বনাম causal span-tree) দিয়ে নির্ভুলভাবে আলাদা করতে পারবেন, আর প্রতিটা ঠিক কোন প্রশ্নের উত্তর দেয় তা ব্যাখ্যা করতে পারবেন
  • Prometheus-এর metric data model (নাম + label set) আর pull-based scraping মডেল ব্যাখ্যা করতে পারবেন, আর এটা কেন push-এর তুলনায় একটা ইচ্ছাকৃত design decision তা যুক্তি দিয়ে বলতে পারবেন
  • Metric cardinality-র নির্ভুল সংজ্ঞা (distinct label-value combination-এর সংখ্যা) দিয়ে বাস্তব সংখ্যা হিসাব করতে পারবেন, আর কেন Kubernetes-এর disposable Pod-এর নাম label হিসেবে ব্যবহার করলে একটা metrics backend চুপচাপ ভেঙে পড়তে পারে তা ব্যাখ্যা করতে পারবেন
  • একটা containerized platform-এ sidecar/per-node collector (যেমন OpenTelemetry Collector) কীভাবে তিন pillar-ই স্থানীয়ভাবে জড়ো করে আলাদা, বিশেষায়িত backend-এ পাঠায় তা বর্ণনা করতে পারবেন
  • trace_id-এর মতো একটা shared identifier দিয়ে log আর trace-এর মধ্যে correlation ঠিক কীভাবে কাজ করে তা কোড দিয়ে বাস্তবায়ন করে দেখাতে পারবেন — একটা সন্দেহজনক log line থেকে তার সম্পূর্ণ trace খুঁজে বের করে

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

distributed-systems/distributed-tracing লেসন একটা প্রশ্নের সম্পূর্ণ উত্তর দিয়েছে: একটা নির্দিষ্ট request কোথায় সময় খেল। Span, parent-child tree, traceparent propagation, sampling economics, critical path — সবকিছু সেখানে হাতে-কলমে তৈরি হয়েছে, আর আজ সেই কাজ পুনরাবৃত্তি হবে না। virtualization-cloud/kubernetes-architecture আর virtualization-cloud/pods-controllers-services-ingress লেসন দেখিয়েছে কীভাবে একটা Pod disposable — মরে যায়, নতুন নাম আর নতুন IP নিয়ে replace হয়, web-7d9f8c6b5-x2kpl জাতীয় hash-suffixed নাম প্রতিটা reconciliation-এ নতুন করে জন্মায়। এই দুই লেসনের সুতো আজ একসাথে বাঁধা হবে।

Trace একা যথেষ্ট না। ধরুন dashboard-এ একটা লাল গ্রাফ দেখলেন — p99 latency উঠে গেছে। প্রশ্ন: কতগুলো request প্রভাবিত? এটা কি নতুন প্রবণতা নাকি এক মুহূর্তের spike? কোন trace-টা টেনে দেখব — লক্ষ লক্ষ request-এর মধ্যে কোনটা “the” representative slow one? একটা trace আপনাকে একটা request-এর গল্প বলে, নিখুঁতভাবে — কিন্তু “সিস্টেম সামগ্রিকভাবে কেমন আছে” এই প্রশ্নের উত্তর একটা trace দিতে পারে না, আর “ঠিক কী বার্তা inventory service প্রিন্ট করেছিল ব্যর্থ হওয়ার মুহূর্তে” এই প্রশ্নেরও না।

এখানেই বাকি দুই pillar — metrics আর logs — ঢোকে। আজকের লেসনের কাজ trace-mechanics পুনরায় শেখানো না; বরং তিনটা pillar-কে তাদের data shape-এর ভিত্তিতে নির্ভুলভাবে আলাদা করা, Prometheus-এর metric model concretely বোঝা, metric cardinality-র বিপদ বাস্তব সংখ্যায় দেখা, আর সবশেষে দেখা কীভাবে একটা containerized প্ল্যাটফর্মে এই তিনটা pillar একসাথে — আলাদা silo না হয়ে — কাজ করে।

মূল ধারণা

তিন pillar, এক প্রশ্নের উত্তর না — তিন আলাদা প্রশ্নের উত্তর

Observability মানে “লগ + মেট্রিক্স + ট্রেস থাকা” না — মানে সিস্টেমের ভেতরের অবস্থা তার বাহ্যিক আউটপুট থেকে পুনর্গঠন করতে পারা, নতুন প্রশ্ন এলেও নতুন করে instrument না করে। তিনটা pillar এই লক্ষ্যে ভিন্ন ভূমিকা রাখে — আর সেই ভূমিকার পার্থক্যটা বোঝার সবচেয়ে নির্ভুল উপায় তাদের use-case না, তাদের data shape।

পার্থক্যটা শুধু বিমূর্ত না — সরাসরি ব্যবহারিক পরিণতি আছে। একটা metric যখন লেখা হয় (http_requests_total{method="GET"} বাড়ল ১ দিয়ে), তখন কোন নির্দিষ্ট request-টা এই বৃদ্ধি ঘটাল, সেই তথ্যটা আর নেই — শুধু একটা aggregate সংখ্যা টিকে থাকে। এটাই metric-এর শক্তি (হাজার request-কে এক সংখ্যায় সংকুচিত করা, তাই সস্তা storage আর query) আর একই সাথে সীমাবদ্ধতা (কোনো individual request-এর গল্প বলতে পারে না)। উল্টোদিকে একটা log line নির্দিষ্ট একটা মুহূর্তের সম্পূর্ণ বিস্তারিত রেকর্ড রাখে — কিন্তু প্রতিটা event আলাদাভাবে সংরক্ষণ করতে হয়, তাই high-volume-এ storage/search খরচ দ্রুত বাড়ে।

তিনটা pillar-ই একই root cause-এর ভিন্ন ছায়া হতে পারে — inventory service ধীর হয়ে গেছে বলে (১) latency metric-এর p99 বেড়েছে, (২) inventory-র error log বেড়েছে, আর (৩) trace-এ দেখা যাচ্ছে reserve_stock span-ই সময় খাচ্ছে — কিন্তু কেউই একা সম্পূর্ণ গল্প বলে না। Metric প্রথম alert দেয় (“কিছু একটা খারাপ”), log প্রথম বিস্তারিত সূত্র দেয় (“ঠিক কী ভুল হয়েছে”), আর trace নিশ্চিত করে কোথায় (“কোন hop-এ”)। এই ত্রয়ীর প্রতিটার নিজস্ব কাজ — একটা অন্যটার বিকল্প না।

ভেতরে কী ঘটছে

Prometheus data model — নাম + label, একসাথে identity

Metric pillar-এর সবচেয়ে ব্যাপকভাবে ব্যবহৃত বাস্তবায়ন Prometheus। এর data model সহজ কিন্তু নির্ভুল: প্রতিটা time series একটা metric নাম আর একটা label সেট (key-value জোড়া) দিয়ে সম্পূর্ণভাবে identify হয়:

http_requests_total{method="GET", path="/api/users", status="200"}

এখানে http_requests_total metric-এর নাম (কী মাপা হচ্ছে), আর {method="GET", path="/api/users", status="200"} তার label set (কোন dimension-এ ভাঙা হচ্ছে)। এই দুটো মিলে একটা একক, অনন্য time series — নামটা একই রেখে label বদলালেই (যেমন method="POST") সম্পূর্ণ ভিন্ন একটা time series, নিজের আলাদা স্টোরেজ লাইন নিয়ে। এই একটা লাইন — নাম + label-এর সম্পূর্ণ combination — Prometheus-এর পরিভাষায় একটা “time series”, আর ঠিক এই সংজ্ঞাটাই সামনের বিভাগে cardinality-র হিসাবের ভিত্তি হবে।

Metric cardinality — distinct label-value combination-এর সংখ্যা

এখানেই আজকের সবচেয়ে গুরুত্বপূর্ণ, সবচেয়ে বেশি-ভুল-বোঝা ধারণা: metric cardinality। সংজ্ঞাটা নির্ভুল রাখা জরুরি — cardinality মানে metric-এর সংখ্যা না, একটা একক metric-এর label set যতগুলো distinct combination নিতে পারে তার সংখ্যা। উপরের উদাহরণে method যদি ৫টা মান নেয় (GET/POST/PUT/DELETE/PATCH), আর path ২৫টা, আর status ৫টা, তাহলে সেই একটা metric-এর cardinality হলো 5 x 25 x 5 = 625 — Prometheus-কে ভেতরে ৬২৫টা আলাদা time series ধরে রাখতে হবে, একটা metric নামের জন্যই।

সমস্যা তখন শুরু হয় যখন কোনো label-এর নিজের সম্ভাব্য মানের সংখ্যা কার্যত unbounded। আর pods-controllers-services-ingress লেসনের কেন্দ্রীয় থিমটাই এখানে সরাসরি ফিরে আসে — Kubernetes-এর Pod disposable। একটা Deployment-এর ৮টা replica স্থিরভাবে চলে মনে হলেও, প্রতিটা rolling update, প্রতিটা autoscale-driven churn, প্রতিটা node-failure recovery একটা সম্পূর্ণ নতুন Pod object তৈরি করে — নতুন নাম, web-7d9f8c6b5-x2kpl জাতীয় hash suffix-সহ। যদি কেউ pod_name-কে সরাসরি একটা metric label বানায়, প্রতিটা নতুন Pod একটা নতুন time series তৈরি করে — পুরনোটা কখনো মোছেও না (retention window পার না হওয়া পর্যন্ত), শুধু জমতে থাকে।

পরের বিভাগে এই দাবিটা হাতে-হিসাব-করা বাস্তব সংখ্যা দিয়ে দেখানো হবে — কতটা নাটকীয় এই পার্থক্য, শুধু একটা label-choice বদলে।

উদাহরণ

হাতে হিসাব — pod_name বনাম deployment_name

ধরুন একটা microservice ১৫টা metric expose করে (counter, gauge, histogram মিলিয়ে — একটা typical instrumented service-এর জন্য বাস্তবসম্মত সংখ্যা), আর প্রতিটা metric-এর একই তিনটা base label আছে: method (৫টা মান), path (২৫টা route), status_class (৫টা — 2xx/3xx/4xx/5xx/429)।

Base combination, প্রতি metric: 5 x 25 x 5 = 625।

এখন চতুর্থ label যোগ করার প্রশ্ন — কোন Pod/Deployment থেকে এই সংখ্যাটা এসেছে সেটা জানা দরকার, drill-down-এর জন্য। দুইটা বিকল্প:

বিকল্প ১ — deployment_name লেবেল। এই namespace-এ এই service-এর জন্য একটাই Deployment চলে, তাই এই লেবেলের মান সবসময় একটাই — distinct value = ১।

বিকল্প ২ — pod_name লেবেল। Deployment স্থির অবস্থায় ৮টা replica চালায়, কিন্তু ৩০ দিনের retention window জুড়ে: সপ্তাহে ৫টা rolling deploy (প্রতিটা weekday একবার), প্রতিটাতে ৮টা replica-ই নতুন নামে replace হয় — 30/7 ≈ 4.3 সপ্তাহে 5 x 4.3 ≈ 21 rollout, 21 x 8 = 168টা নতুন Pod নাম। এর ওপর autoscaler-এর দৈনিক churn — দিনে গড়ে ২টা করে অতিরিক্ত Pod তৈরি/ধ্বংস — ৩০ দিনে আরও 2 x 30 = 60টা নাম। মোট distinct pod_name = 168 + 60 = 228।

এই ব্যবধান কাল্পনিক না — এটা সরাসরি গুণফলের arithmetic: base combination (৬২৫) অপরিবর্তিত থাকে, শুধু শেষ ফ্যাক্টরটা 1 থেকে 228-এ যায়, আর সেই একই অনুপাতে পুরো metric backend-এর memory/storage footprint বাড়ে। একটা মাত্র “সুবিধাজনক মনে হওয়া” label-choice — “pod-level granularity রাখি, ভালো তো” — পুরো namespace-এর জন্য metrics backend-কে কার্যত অচল করে দিতে পারে, কোনো কোড-বাগ ছাড়াই।

ছবিতে

একটা containerized platform-এ তিন pillar-এর পথ

┌─────────────────────────── worker node ───────────────────────────────┐
│  ┌──────────── Pod: web-7d9f8c6b5-x2kpl ────────────┐                  │
│  │  app container                                     │                  │
│  │   ├─ /metrics endpoint  (Prometheus pull করে নেয়)   │                  │
│  │   ├─ stdout/stderr logs (structured JSON)          │                  │
│  │   └─ OTel SDK spans     (trace_id সহ প্রতিটা log-এ)│                  │
│  └──────────────────────┬──────────────────────────┘                  │
│                          │  সব তিন signal                                │
│                          ▼                                              │
│              ┌───────────────────────────┐                              │
│              │  OpenTelemetry Collector    │  ◄── sidecar বা per-node    │
│              │  (receive → process → export)│      DaemonSet — উভয়ই common │
│              └──────┬──────────┬──────────┘                              │
│                     │          │        │                                │
└─────────────────────┼──────────┼────────┼────────────────────────────────┘
                       ▼          ▼        ▼
              ┌────────────┐┌──────────┐┌───────────┐
              │ Prometheus/││  Loki /  ││ Jaeger /   │
              │   Mimir    ││Elastic-  ││  Tempo     │
              │ (metrics)  ││ search   ││ (traces)   │
              │            ││ (logs)   ││            │
              └────────────┘└──────────┘└───────────┘
                       │          │        ▲
                       │          └────────┘
                       │     trace_id লগ-লাইনে থাকায়
                       │     এক ক্লিকে trace-এ jump
                       ▼
                 dashboard/alert
স্থাপত্যএকটা Pod-এর ভেতর থেকে তিন pillar আলাদা backend-এ পৌঁছানোর পথ — সবাই স্থানীয় collector দিয়ে যায়, backend swap করা যায় কোড না ছুঁয়ে।

লক্ষ্য করুন — এই diagram-এ প্রতিটা signal-এর নিজের specialized backend, কারণ প্রতিটার storage/query-pattern মৌলিকভাবে ভিন্ন (aggregatable time series বনাম full-text-searchable event বনাম tree-structured span)। একটাই backend সবগুলো ভালোভাবে সামলাতে পারে না — কিন্তু collector layer-টা সবাইকে এক জায়গা থেকে জড়ো করে, তাই application কোড কখনো জানেই না backend আসলে কী।

নিজে চালিয়ে দেখুন

EXPERIMENT

Cardinality simulation — pod_name বনাম deployment_name, বাস্তব সংখ্যায়

যেকোনো OS (Python 3, কোনো dependency ছাড়াই)· ১০ মিনিট
# cardinality_sim.py -- compute actual Prometheus time-series counts for
# two label-schema choices on the same set of metrics: one using raw
# Kubernetes Pod name as a label (HIGH cardinality, because Pods are
# disposable -- kubernetes-architecture / pods-controllers-services-ingress
# lessons' theme), one using Deployment name instead (LOW cardinality,
# because a Deployment is a stable, long-lived identity).

# --- shared label dimensions, common to every metric on this service ---
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]                 # 5
PATHS = [f"/api/route-{i}" for i in range(25)]                      # 25
STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "429"]                 # 5

base_combinations = len(METHODS) * len(PATHS) * len(STATUS_CLASSES)

# --- the service exposes this many distinct metrics (counters, gauges,
# histogram buckets collapsed to one row each for this estimate) ---
N_METRICS = 15

# --- pod-identity churn over a 30-day retention window ---
STEADY_STATE_REPLICAS = 8
ROLLOUTS_PER_WEEK = 5          # one rolling deploy per weekday
WEEKS = 30 / 7
AUTOSCALE_CHURN_PER_DAY = 2    # extra pods created/destroyed by HPA activity
DAYS = 30

pod_names_from_rollouts = round(ROLLOUTS_PER_WEEK * WEEKS) * STEADY_STATE_REPLICAS
pod_names_from_autoscale = AUTOSCALE_CHURN_PER_DAY * DAYS
DISTINCT_POD_NAMES_OVER_WINDOW = pod_names_from_rollouts + pod_names_from_autoscale

# --- the low-cardinality alternative: this namespace runs exactly 1
# Deployment for this service, so deployment_name has exactly 1 value ---
DISTINCT_DEPLOYMENT_NAMES = 1

print("=== base label combinations (method x path x status_class) ===")
print(f"  {len(METHODS)} methods x {len(PATHS)} paths x {len(STATUS_CLASSES)} status_classes "
      f"= {base_combinations} combinations per metric (before the pod/deployment label)")

print("\n=== pod-name churn over a 30-day retention window ===")
print(f"  rollouts: {round(ROLLOUTS_PER_WEEK * WEEKS)} rolling deploys x "
      f"{STEADY_STATE_REPLICAS} replicas each = {pod_names_from_rollouts} distinct pod names")
print(f"  autoscale churn: {AUTOSCALE_CHURN_PER_DAY}/day x {DAYS} days = {pod_names_from_autoscale} distinct pod names")
print(f"  total distinct pod_name values seen in the window = {DISTINCT_POD_NAMES_OVER_WINDOW}")

print("\n=== total time-series count across all N_METRICS ===")
low_card_per_metric = base_combinations * DISTINCT_DEPLOYMENT_NAMES
high_card_per_metric = base_combinations * DISTINCT_POD_NAMES_OVER_WINDOW
low_card_total = low_card_per_metric * N_METRICS
high_card_total = high_card_per_metric * N_METRICS

print(f"  LOW  cardinality (label = deployment_name, {DISTINCT_DEPLOYMENT_NAMES} distinct value):")
print(f"       {base_combinations} x {DISTINCT_DEPLOYMENT_NAMES} = {low_card_per_metric} series/metric"
      f"  x {N_METRICS} metrics = {low_card_total:,} total series")
print(f"  HIGH cardinality (label = pod_name, {DISTINCT_POD_NAMES_OVER_WINDOW} distinct values over 30 days):")
print(f"       {base_combinations} x {DISTINCT_POD_NAMES_OVER_WINDOW} = {high_card_per_metric:,} series/metric"
      f"  x {N_METRICS} metrics = {high_card_total:,} total series")

ratio = high_card_total / low_card_total
print(f"\n  blowup factor: {ratio:.0f}x more time series just from this one label choice")
print(f"  (and this is ONE service -- a namespace with 50 such services multiplies this again)")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

=== base label combinations (method x path x status_class) ===
  5 methods x 25 paths x 5 status_classes = 625 combinations per metric (before the pod/deployment label)

=== pod-name churn over a 30-day retention window ===
  rollouts: 21 rolling deploys x 8 replicas each = 168 distinct pod names
  autoscale churn: 2/day x 30 days = 60 distinct pod names
  total distinct pod_name values seen in the window = 228

=== total time-series count across all N_METRICS ===
  LOW  cardinality (label = deployment_name, 1 distinct value):
       625 x 1 = 625 series/metric  x 15 metrics = 9,375 total series
  HIGH cardinality (label = pod_name, 228 distinct values over 30 days):
       625 x 228 = 142,500 series/metric  x 15 metrics = 2,137,500 total series

  blowup factor: 228x more time series just from this one label choice
  (and this is ONE service -- a namespace with 50 such services multiplies this again)

সংখ্যাগুলো মন দিয়ে পড়ুন: base combination (৬২৫) দুই বিকল্পেই অপরিবর্তিত — cardinality বিস্ফোরণের পুরো কারণ একটামাত্র factor, DISTINCT_POD_NAMES_OVER_WINDOW। আর সেই সংখ্যাটা (২২৮) নিজেই আসছে সম্পূর্ণ স্বাভাবিক Kubernetes আচরণ থেকে — কোনো bug না, শুধু নিয়মিত rolling deploy আর autoscaling, pods-controllers-services-ingress লেসনের সেই একই “cattle না pets” নীতি। ২৮০-গুণ ফ্যাক্টরটা মনে রাখার মতো — এটা একটা মাত্র service-এর হিসাব; বাস্তব cluster-এ ৫০টা এমন service থাকলে সংখ্যাটা আবার ৫০ গুণ বাড়ে।

এটা কী প্রমাণ করে

Metric cardinality-র সংজ্ঞা (distinct label-value combination) শুধু একটা label-choice বদলে কতটা নাটকীয়ভাবে backend-এর সংরক্ষণ-চাপ বদলে দেয় — real arithmetic দিয়ে, কোনো অনুমান ছাড়া।

নিজে বানান

BUILD IT

find_trace_for_log — একটা suspicious log line থেকে সম্পূর্ণ trace-এ jump

Python · ●●●○○
  1. toy log line-এর একটা তালিকা বানান — প্রতিটাতে service, level, message, আর একটা trace_id
  2. distributed-tracing লেসনের সেই একই checkout trace-এর span list পুনরায় ব্যবহার করুন (trace_id, span_id, parent_span_id, name, start_ms, duration_ms)
  3. find_trace_for_log(log_line, spans) লিখুন — শুধু trace_id দিয়ে filter করে, parent-edge দিয়ে tree assemble করে
  4. একটা ERROR log বেছে ফাংশনটা চালান, দেখুন পুরো trace কতটা সহজে বেরিয়ে আসে

লক্ষ্য: log এবং trace — দুইটা আলাদা backend-এ বসা দুইটা আলাদা pillar — কীভাবে একটা মাত্র shared identifier (trace_id) দিয়ে জোড়া লাগে, সেই correlation মেকানিজমটা কোডে দেখানো। এখানে কোনো নতুন span-mechanic নেই — span assembly-র যুক্তি (parent-edge দিয়ে tree, timestamp দিয়ে না) হুবহু distributed-systems/distributed-tracing-এর TraceCollector-এর মতোই; আজকের নতুনত্ব শুধু log-থেকে-trace-এ jump করার entry point।

# log_trace_correlation.py -- given ONE suspicious log line, find and print
# the FULL trace it belongs to. Reuses the exact checkout-request example
# (gateway -> cart -> inventory -> queue -> notifier) and the exact span
# fields (trace_id, span_id, parent_span_id, name, start_ms, duration_ms)
# from distributed-systems/distributed-tracing -- no new tracing mechanics
# introduced here, only the CORRELATION step: trace_id as the shared key
# between the logs backend and the traces backend.

LOGS = [
    {"ts": "10:02:01.004", "level": "INFO",  "service": "gateway",
     "trace_id": "tr-88", "msg": "GET /checkout accepted"},
    {"ts": "10:02:01.041", "level": "INFO",  "service": "cart",
     "trace_id": "tr-88", "msg": "cart processed, 3 items"},
    {"ts": "10:02:01.520", "level": "ERROR", "service": "inventory",
     "trace_id": "tr-88", "msg": "reserve_stock timed out after 503ms, retrying"},
    {"ts": "10:02:03.900", "level": "INFO",  "service": "gateway",
     "trace_id": "tr-91", "msg": "GET /checkout accepted"},
    {"ts": "10:02:03.955", "level": "INFO",  "service": "inventory",
     "trace_id": "tr-91", "msg": "reserve_stock ok"},
]

# same six spans as the distributed-tracing lesson's worked example
SPANS = [
    dict(trace_id="tr-88", span_id="g1", parent_span_id="",   name="gateway GET /checkout",  start_ms=0,   duration_ms=562),
    dict(trace_id="tr-88", span_id="a9", parent_span_id="g1",  name="auth-check verify_token", start_ms=2,   duration_ms=33),
    dict(trace_id="tr-88", span_id="c1", parent_span_id="g1",  name="cart process_cart",       start_ms=8,   duration_ms=537),
    dict(trace_id="tr-88", span_id="i1", parent_span_id="c1",  name="inventory reserve_stock", start_ms=15,  duration_ms=503),
    dict(trace_id="tr-88", span_id="q1", parent_span_id="c1",  name="cart.publish order.event", start_ms=549, duration_ms=2),
    dict(trace_id="tr-88", span_id="k1", parent_span_id="q1",  name="notifier.consume",        start_ms=601, duration_ms=41),
    dict(trace_id="tr-91", span_id="g2", parent_span_id="",   name="gateway GET /checkout",  start_ms=0,   duration_ms=58),
    dict(trace_id="tr-91", span_id="i2", parent_span_id="g2",  name="inventory reserve_stock", start_ms=6,   duration_ms=41),
]


def find_trace_for_log(log_line, spans):
    """The correlation mechanism: the log line already carries trace_id
    (the collector/SDK stamped it at emit time) -- jumping to the full
    trace is just a filter + a parent-edge tree assembly, exactly like
    TraceCollector.children_map/roots in the distributed-tracing lesson."""
    tid = log_line["trace_id"]
    matched = [s for s in spans if s["trace_id"] == tid]
    if not matched:
        return tid, []
    by_id = {s["span_id"]: s for s in matched}
    children = {}
    for s in matched:
        children.setdefault(s["parent_span_id"], []).append(s)
    roots = [s for s in matched if s["parent_span_id"] not in by_id]

    ordered = []
    def walk(s, depth):
        ordered.append((depth, s))
        for c in sorted(children.get(s["span_id"], []), key=lambda x: x["start_ms"]):
            walk(c, depth + 1)
    for r in sorted(roots, key=lambda x: x["start_ms"]):
        walk(r, 0)
    return tid, ordered


suspicious = LOGS[2]  # the ERROR line from inventory
print("suspicious log line:")
print(f"  [{suspicious['ts']}] {suspicious['level']:5} {suspicious['service']:10} "
      f"trace_id={suspicious['trace_id']}  \"{suspicious['msg']}\"")

trace_id, tree = find_trace_for_log(suspicious, SPANS)
print(f"\ncorrelated via trace_id={trace_id} -> full trace ({len(tree)} spans):")
for depth, s in tree:
    indent = "  " * depth
    print(f"  {indent}{s['name']:<32} start={s['start_ms']:>4}ms dur={s['duration_ms']:>4}ms  span={s['span_id']}")

print("\nconclusion: the ERROR is 'reserve_stock timed out' at 10:02:01.520 --")
print("the correlated trace shows inventory's reserve_stock span alone is 503ms")
print("of the request's 562ms total -- the log told us something failed,")
print("the trace (already built in the prerequisite lesson) tells us it's the")
print("single most expensive span in the whole call tree, not a red herring.")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

suspicious log line:
  [10:02:01.520] ERROR inventory  trace_id=tr-88  "reserve_stock timed out after 503ms, retrying"

correlated via trace_id=tr-88 -> full trace (6 spans):
  gateway GET /checkout            start=   0ms dur= 562ms  span=g1
    auth-check verify_token          start=   2ms dur=  33ms  span=a9
    cart process_cart                start=   8ms dur= 537ms  span=c1
      inventory reserve_stock          start=  15ms dur= 503ms  span=i1
      cart.publish order.event         start= 549ms dur=   2ms  span=q1
        notifier.consume                 start= 601ms dur=  41ms  span=k1

conclusion: the ERROR is 'reserve_stock timed out' at 10:02:01.520 --
the correlated trace shows inventory's reserve_stock span alone is 503ms
of the request's 562ms total -- the log told us something failed,
the trace (already built in the prerequisite lesson) tells us it's the
single most expensive span in the whole call tree, not a red herring.

তিনটা মুহূর্ত মন দিয়ে পড়ুন: (১) find_trace_for_log-এর ভেতরে trace-tree assembly ঠিক distributed-tracing-এর TraceCollector-এরই সংক্ষিপ্ত রূপ — parent-edge দিয়ে গাছ, timestamp দিয়ে না। কোনো নতুন ধারণা তৈরি হয়নি, শুধু পুনর্ব্যবহার হয়েছে। (২) দ্বিতীয় trace (tr-91) সম্পূর্ণ সুস্থ — একই query একটা healthy trace-এর জন্য চালালে কিছুই সন্দেহজনক দেখাত না, তাই correlation সবসময় একটা নির্দিষ্ট সন্দেহজনক log line থেকে শুরু হয়, সব trace থেকে না। (৩) এই পুরো mechanism কাজ করার একমাত্র পূর্বশর্ত — log emit করার সময়েই কোডে trace_id-টা হাতে থাকা লাগে, যেটা OTel SDK-র auto-instrumentation দেয় (distributed-tracing লেসনের traceparent propagation-এর সরাসরি ফল)।

নিজে বাড়ান:

  1. LOGS-এ আরেকটা trace_id (tr-91-এর একটা INFO log) দিয়ে ফাংশনটা চালান — দেখুন healthy trace-এর জন্য কী পার্থক্য দেখা যায়।
  2. একটা log line-এ trace_id না থাকলে কী হবে তা handle করুন — বাস্তবে এটা ঘটে যখন কোনো পুরনো, un-instrumented কোড-পথ থেকে log আসে।
  3. ফাংশনটা extend করুন যাতে সেই trace-এর critical path-ও highlight করে (আগের লেসনের critical_path ফাংশনের মতো) — log থেকে সরাসরি “কোন span-টা আসল দোষী” পর্যন্ত এক লাফে পৌঁছানো।

বাস্তব সিস্টেমে

বাস্তব জগতে — কে কী চালায়, আর cardinality-র বিরুদ্ধে সুরক্ষা

OpenTelemetry-এর একীভূত অবস্থান। distributed-tracing লেসনে OTel-এর tracing-অংশ (API/SDK/Collector) দেখা হয়েছিল। একই প্রজেক্ট এখন metrics আর logs-এর জন্যও একই vocabulary আর একই Collector pipeline অফার করে — একটাই instrumentation, তিনটা signal, backend-স্বাধীন। এটাই কেন CNCF landscape-এ OTel আজ tracing-শুধু টুল থেকে “the” observability standard-এ পরিণত হয়েছে।

Grafana stack বনাম Elastic stack বনাম managed SaaS। ওপরের diagram-এর তিনটা backend-slot বাস্তবে ভরা যায় ভিন্নভাবে — Grafana ecosystem-এ Mimir (metrics) + Loki (logs) + Tempo (traces), Elastic ecosystem-এ Elasticsearch একাই logs আর (বিভিন্ন প্লাগিন দিয়ে) trace-ও ধরে, অথবা Datadog/New Relic-এর মতো managed SaaS একই তিনটাই এক UI-তে বান্ডিল করে। OTel Collector-ই সেই লেয়ার যেটা এই তিন পছন্দের মধ্যে সুইচ করা কোড না ছুঁয়ে সম্ভব করে — ঠিক kubernetes-architecture লেসনের API server বা container-runtime-oci-র CRI-এর মতো একটা stable-contract abstraction।

Cardinality-র বিরুদ্ধে বাস্তব সুরক্ষা। Prometheus-এ sample_limit scrape-config-এ প্রতি target সর্বোচ্চ কতগুলো series গ্রহণযোগ্য তার একটা hard cap বসানো যায় — সীমা পার হলে পুরো scrape বাতিল হয়ে যায়, চুপচাপ overload না হয়ে। Grafana Mimir-এর মতো backend cardinality-analysis টুল দেয় যা exactly এই প্রশ্নের উত্তর দেয় — “কোন metric, কোন label, সবচেয়ে বেশি series তৈরি করছে?” — deploy করার আগেই ধরার জন্য।

যে ভুলগুলো সবাই করে

“Metrics-ই যথেষ্ট — dashboard দিয়ে সব alert ধরা যায়, logs/traces optional extra।”

Metric-এর data shape-ই এই ধারণাকে অসম্ভব করে দেয় — একবার একটা counter বাড়ানো হয়ে গেলে কোন নির্দিষ্ট request সেটা ঘটিয়েছে সেই তথ্যটা চিরতরে হারিয়ে যায়, শুধু aggregate সংখ্যা টিকে থাকে। Metric বলে “কিছু একটা খারাপ”, কিন্তু “ঠিক কী ভুল হয়েছে” (log) বা “কোন নির্দিষ্ট request প্রভাবিত, কোথায়” (trace) — এই প্রশ্নগুলোর উত্তর metric গঠনগতভাবেই দিতে পারে না।

“Log-এ trace_id বসিয়ে দিলেই correlation স্বয়ংক্রিয়ভাবে কাজ করে, কোনো backend-wiring লাগে না।”

trace_id ফিল্ডটা log line-এ থাকা শুধু সম্ভাবনা তৈরি করে — build section-এর find_trace_for_log-এর মতো কেউ (dashboard UI, বা explicit query) সেই ফিল্ডটা আসলে ব্যবহার করে trace backend-এ query না চালালে correlation ঘটে না। বাস্তব backend-এ এটা করতে হয় explicit UI feature হিসেবে (Grafana-র “log-to-trace” link, exemplar) — trace_id থাকা প্রয়োজনীয় শর্ত, যথেষ্ট শর্ত না।

“Cardinality সমস্যা মানে 'অনেকগুলো metric আছে' — কম metric রাখলেই নিরাপদ।”

Cardinality metric-এর সংখ্যা না, একটা একক metric-এর label-value combination-এর সংখ্যা মাপে। আজকের হাতে-হিসাবের উদাহরণে ১৫টা metric-এর সবই একই কম-cardinality label ব্যবহার করলে মোট মাত্র ৯,৩৭৫টা series — কিন্তু সেই একই ১৫টা metric-এ একটামাত্র high-cardinality label (pod_name) যোগ করলেই সংখ্যাটা ২৩ লক্ষের বেশি হয়ে যায়। একটামাত্র খারাপ label-choice কয়েক ডজন ভালো metric-এর চেয়ে বেশি ক্ষতিকর হতে পারে।

“Prometheus-এর pull-based scraping মানে service-কে backend-এর ঠিকানা জানতে হয় — push-এর চেয়ে বেশি tightly coupled।”

উল্টো — pull মডেলে service কিছুই জানে না Prometheus সম্পর্কে; সে শুধু নিজের /metrics endpoint expose করে, আর Prometheus নিজে (service discovery দিয়ে) খুঁজে নেয় কোথায় scrape করতে হবে। Push মডেলেই বরং service-কে backend-এর ঠিকানা/protocol জানতে হতো এবং প্রতিটা service-কে নিজের push-reliability সামলাতে হতো — pull মডেল এই coupling-টাই এড়ায়, আর scrape-ব্যর্থতা নিজেই একটা বিনামূল্যের health-signal হয়ে যায়।

বুঝেছেন কি না দেখুন

1তিন pillar-কে তাদের data shape দিয়ে সংজ্ঞায়িত করুন — metric, log, trace প্রতিটার data shape কী, আর প্রতিটা কোন এক-বাক্যের প্রশ্নের উত্তর দেয়?স্মরণ
Metric: aggregatable numeric time series — প্রতি timestamp-এ একটা সংখ্যা; উত্তর দেয় “সামগ্রিকভাবে সিস্টেম এখন সুস্থ কি না?”। Log: discrete, timestamped event, সাধারণত text (structured JSON আজ প্রাধান্য পাচ্ছে queryability-র কারণে); উত্তর দেয় “ঠিক এই মুহূর্তে কী ঘটেছিল?”। Trace: একটা request-এর causal span-tree (distributed-systems/distributed-tracing-এর বিষয়); উত্তর দেয় “এই নির্দিষ্ট request কোথায় সময় খেল?”।
2Prometheus কেন push-এর বদলে pull-based scraping বেছে নিয়েছে? দুইটা concrete কারণ দিন।যুক্তি
প্রথমত, decoupling — service নিজে Prometheus-এর ঠিকানা বা প্রোটোকল কিছুই জানে না, শুধু একটা /metrics endpoint expose করে; Prometheus নিজে service-discovery দিয়ে টার্গেট খুঁজে নেয়। দ্বিতীয়ত, scrape-ব্যর্থতা নিজেই বিনামূল্যের health-signal — endpoint scrape করা না গেলে (timeout/error) Prometheus সরাসরি জানে target down, আলাদা কোনো heartbeat mechanism লাগে না।
3Experiment section-এর হিসাবে base combination (৬২৫) দুই বিকল্পেই এক থাকল, তবু মোট series-সংখ্যা 9,375 থেকে 2,137,500-এ গেল — ২২৮ গুণ। এই বিস্ফোরণের একমাত্র উৎস কী, আর সেই সংখ্যাটা (২২৮) কোথা থেকে এলো?প্রয়োগ
একমাত্র উৎস: distinct label-value-এর সংখ্যা 1 (deployment_name) থেকে 228-এ (pod_name) যাওয়া — বাকি সব ফ্যাক্টর অপরিবর্তিত। 228 সংখ্যাটা এসেছে ৩০ দিনের retention window-এ স্বাভাবিক Pod churn থেকে — 21টা rolling deploy x 8 replica = 168টা নতুন Pod নাম, প্লাস autoscaler-এর দৈনিক churn 2 x 30 = 60টা, মোট 168 + 60 = 228। এটা কোনো bug না — এটাই Kubernetes-এর স্বাভাবিক, ইচ্ছাকৃত “disposable Pod” আচরণ।
4Build section-এর find_trace_for_log-এ correlation ঠিক কোন একটা field-এর ওপর ভিত্তি করে কাজ করে, আর সেই field log line-এ পৌঁছায় কীভাবে (কোন আগের লেসনের mechanism থেকে)?প্রয়োগ
trace_id field-এর ওপর — শুধু সেই ID দিয়ে filter করে সব span বের করা হয়, তারপর distributed-tracing-এর মতোই parent-edge দিয়ে tree assemble করা হয়। এই trace_id লগ লাইনে পৌঁছায় সেই লেসনের context-propagation mechanism থেকেই — OTel SDK-এর auto-instrumentation active span থেকে trace_id পড়ে প্রতিটা log-emit-এ বসিয়ে দেয়, কোনো ম্যানুয়াল কোড ছাড়াই।
5Sidecar/per-node OpenTelemetry Collector pattern-টা কেন একই স্থাপত্যগত যুক্তি অনুসরণ করে যা kubernetes-architecture লেসনের API server বা container-runtime-oci-র CRI-তে দেখা গিয়েছিল?যুক্তি
তিনটাই একই প্যাটার্ন — একটা stable, vendor-neutral interface-এর পেছনে বাস্তবায়ন লুকানো। Application কোড শুধু OTel API/SDK-এর সাথে কথা বলে (ঠিক যেমন kubelet শুধু CRI-এর সাথে কথা বলে, বা scheduler শুধু API server-এর সাথে), কোন backend (Prometheus বনাম Mimir, Jaeger বনাম Tempo) আসলে data পাচ্ছে তা Collector-এর config-এ লুকানো থাকে — backend বদলাতে application কোড ছুঁতে হয় না, ঠিক যেমন etcd-এর internal storage বদলালেও scheduler-এর কোড বদলায় না।
6আপনাকে একটা নতুন ২০০-service Kubernetes প্ল্যাটফর্মের জন্য observability rollout ডিজাইন করতে বলা হয়েছে। প্রতিটা service-এর logs-এ Pod-স্তরের debugging-ক্ষমতা রাখাও দরকার, কিন্তু metrics backend cardinality-বিস্ফোরণে ভেঙে পড়ুক তা চান না। এই দুই চাহিদা একসাথে কীভাবে মেটাবেন?ডিজাইন
মূল কৌশল: pillar অনুযায়ী label/field granularity ভিন্ন রাখা, একই না। Logs-এ pod_name সমস্যা না — প্রতিটা log line নিজেই একটা discrete event, তাই pod_name-কে একটা log field হিসেবে রাখলে নতুন time series তৈরি হয় না (logs-এর data model metrics-এর মতো aggregatable না)। Metrics-এ pod_name-এর বদলে deployment_name/namespace ব্যবহার করা উচিত — সমষ্টিগত health-এর জন্য এই granularity যথেষ্ট। যদি সত্যিই কোনো নির্দিষ্ট Pod-এর metric দরকার হয় (rare debugging case), সেটা metrics backend-এর বদলে trace-এর tag/attribute হিসেবে রাখা ভালো — trace-এর data volume এমনিতেই sampling দিয়ে নিয়ন্ত্রিত (আগের লেসনের tail-sampling), তাই সেখানে high-cardinality attribute রাখার দাম metrics-এর চেয়ে অনেক কম। এভাবে প্রতিটা pillar তার নিজের data-shape-এর সীমার মধ্যে থেকেই সঠিক granularity পায়।

এরপর কী

আজ আমরা তিন pillar-কে একসাথে বসিয়ে দেখেছি — metrics সংখ্যা বলে, logs ঘটনা বলে, trace (আগের লেসনে ইতিমধ্যে গভীরভাবে শেখা) path বলে, আর sidecar collector + shared trace_id মিলে এই তিনটাকে এক ছাদের নিচে আনে। এতক্ষণ পুরো আলোচনাটাই ধরে নিয়েছে একটা পরিচিত জগত — VM, container, cluster যা আপনি নিজে provision করেছেন, নিজের Pod-এ নিজের /metrics endpoint বসিয়েছেন।

কিন্তু cloud computing-এর একটা গোটা শ্রেণি আছে যেটার সম্পূর্ণ প্রতিশ্রুতিই সার্ভার নিয়ে ভাবা বন্ধ করানো। পরের লেসন, virtualization-cloud/serverless-internals, ঠিক সেই প্রশ্নে যাবে: যখন প্ল্যাটফর্ম বলে “no server” — তখন ভেতরে আসলে কী চলছে? আজকের observability-চিন্তাটাই বা সেই জগতে কীভাবে বদলায়, যখন আপনার কোড নিজের Pod-ই না, একটা transient, আপনার-নিয়ন্ত্রণের-বাইরের execution environment-এ চলে?

আরও পড়ুন

  • Data model — Prometheus Documentation — Prometheus Authors — Prometheus Documentation · Metric নাম + label set দিয়ে time series সংজ্ঞায়িত করার প্রামাণ্য বর্ণনা — আজকের Prometheus data-model অংশের মূল উৎস
  • Why do you pull rather than push? — Prometheus FAQ — Prometheus Authors — Prometheus Documentation · Pull-based scraping-কে ইচ্ছাকৃত design decision হিসেবে ব্যাখ্যা করা প্রামাণ্য FAQ — আজকের 'design choice' দাবির ভিত্তি
  • Signals — OpenTelemetry Documentation — OpenTelemetry Authors — CNCF · Metrics/logs/traces-কে একটা vendor-neutral vocabulary-তে unify করার প্রামাণ্য সংজ্ঞা
  • OpenTelemetry Collector — OpenTelemetry Documentation — OpenTelemetry Authors — CNCF · Sidecar/agent collector-এর receive-process-export pipeline-এর প্রামাণ্য বর্ণনা — আজকের compose-অংশের ভিত্তি