ข้าม​ไป​ยัง​เนื้อหา

ตั้ง stack & สัญญาณ​แรก

บท​ที่​แล้ว​วาง​แบบ​จำลอง​ความคิด​ไว้​จน​ครบ — คราว​นี้​เลิก​พูด​ทฤษฎี เป้าหมาย​ของ​บท​นี้​แคบ​และ​จับ​ต้อง​ได้: รัน ClickStack บน Docker, wire app Ordering เข้า OpenTelemetry, แล้ว​เห็น trace แรก end-to-end โผล่​ใน HyperDX รวม​ถึง DB span จาก EF Core — โดย ยัง​ไม่​เขียน span เอง​สัก​บรรทัด เรา​จะ​พึ่ง auto-instrumentation ล้วนๆ เพื่อ​พิสูจน์​ว่า​สายส่ง​สัญญาณ​เชื่อม​ถึงกัน​จริง แล้ว​ปิด​ท้าย​ด้วย​ความ​จริง​ข้อ​สำคัญ​ว่า​ของ​ฟรี​ก้อน​นี้​เป็น​แค่ พื้น ไม่ใช่ เพดาน

📦 code ตัวอย่าง

code ใน​บท​นี้ instrument platform Order ตัว​เดิม​ที่ #8 สร้าง​ไว้ — repo kaen-food-ordering (กำลัง​จัด​ทำ) เรา​แตะ​เฉพาะ Program.cs ของ context Ordering (FoodOrdering.Host) เพื่อ​เดินสาย OpenTelemetry เข้าไป คร่อม code เดิม — ไม่​แก้ business logic, ไม่​เพิ่ม bounded context, ไม่​แตะ handler ของ Wolverine (นั่น​คือ​บท3) code ทุก​ชิ้น​ใน​บท​นี้ pin version จริง​ไว้​ให้ copy-paste ได้ — ตรวจ​ล่าสุด 2026-07-22

ClickStackClickStackstack observability โอเพน​ซอร์ส​ของ ClickHouse: HyperDX (UI) + ClickHouse (columnar store) + OTel Collector distro; เป็น 'ปลายทาง' ที่​สลับ​ได้Architecture แจก​เป็น image all-in-one ที่​รวม​ทั้ง OTel Collector, ClickHouse (ที่​เก็บ) และ HyperDX (UI) ไว้​ใน container เดียว — เหมาะ​สำหรับ​เรียนรู้​บน​เครื่อง​ตัวเอง เปิด terminal แล้ว​รัน (pin tag ให้​ชัด อย่า​ใช้ latest):

Terminal window
docker run --name clickstack \
-p 8080:8080 -p 4317:4317 -p 4318:4318 -p 8123:8123 \
clickhouse/clickstack-all-in-one:2.31.0

port ทั้ง​สี่​มีหน้าที่​ต่าง​กัน​ชัดเจน จำ​ไว้​เพราะ​เรา​จะ​อ้าง​ถึง​มัน​ทั้ง​คอร์ส:

portใคร​ใช้
8080HyperDX UI — เปิด​ใน​เบราว์เซอร์
4317OTLPOTLPOpenTelemetry Protocol protocol มาตรฐาน​ส่ง telemetry (gRPC :4317 / HTTP :4318)Architecture ผ่าน gRPC (ค่า default ที่ SDK ยิง​มา)
4318OTLP ผ่าน HTTP/protobuf
8123ClickHouse HTTP (query ตรง​เข้า store)
all-in-one image นี้​ไม่ใช่​สำหรับ production

image clickstack-all-in-one ออกแบบ​มา​เพื่อ เรียนรู้/ทดลอง เท่านั้น — storage ไม่ persistent: ปิด container เมื่อไหร่​ข้อมูล​หาย​หมด (ต้อง mount volume ที่ /data/db, /var/lib/clickhouse, /var/log/clickhouse-server ถึง​จะ​เก็บ​อยู่) และ​เอกสาร​ทางการ​ระ​บุตรงๆ ว่า​มัน ไม่​เหมาะ​กับ production สำหรับ production จริง​ต้อง​แยก component ออก​จาก​กัน (หรือ​ใช้ Managed ClickStack บน ClickHouse Cloud) เรา​จะ ไม่ พูด​เรื่อง​นี้​ซ้ำ — บท​นี้​ทุก​อย่าง​คือ dev loop บน​เครื่อง​คุณ

เปิด​เบราว์เซอร์​ไป​ที่ http://localhost:8080 ทำ first-run setup (ตั้ง​บัญชี admin ใน​เครื่อง) พอ​เข้า​ได้​แล้ว ไป​ที่ Team Settings → API Keys เพื่อ​คว้า ingestion API key ไว้ — เรา​จะ​ใช้​มัน​ตอน​คุย​เรื่อง production ใน​ขั้น​ที่ 4

InstrumentationInstrumentationการ​ฝัง code/library ให้ app ปล่อย telemetry; auto = ได้​ฟรี​จาก framework, manual = เขียน​เอง​ให้​มี​ความหมาย​เชิง domain (เป็น 'พื้น' ไม่ใช่ 'เพดาน')Process คือ​การ​ฝัง code/library ให้ app ปล่อย telemetry ออก​มา OpenTelemetry .NET แจก instrumentation แบบ อัตโนมัติ เป็น package แยก​ต่อ framework — ปัก​เข้าไป​แล้ว​ได้ span ของ ASP.NET Core, HttpClient, EF Core และ metric ของ runtime มา​ฟรี​ทันที เพิ่ม​ชุด​นี้​ใน project FoodOrdering.Host:

Terminal window
dotnet add package OpenTelemetry.Extensions.Hosting --version 1.17.0
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol --version 1.17.0
dotnet add package OpenTelemetry.Instrumentation.AspNetCore --version 1.17.0
dotnet add package OpenTelemetry.Instrumentation.Http --version 1.17.0
dotnet add package OpenTelemetry.Instrumentation.Runtime --version 1.17.0
dotnet add package OpenTelemetry.Exporter.Console --version 1.17.0

OpenTelemetry.Extensions.Hosting ดึง core package OpenTelemetry ตาม​มา​เอง (transitive) จึง​ไม่​ต้อง​ปัก​แยก ส่วน Exporter.Console เป็น​ของ dev — ไว้​เอา​ไว้​ดู telemetry ด้วย​ตา​ตอน debug

EF Core instrumentation อยู่​คนละ​สถานะ​กับ​ตัว​อื่น — มัน​ยัง เป็น prerelease ต้อง​ใส่ --prerelease ถึง​จะ​ปัก​ได้:

Terminal window
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore --version 1.17.0-beta.1 --prerelease
EF Core instrumentation ยัง​เป็น beta — พูด​ให้​ชัด อย่า​ปิดบัง

OpenTelemetry.Instrumentation.EntityFrameworkCore ล่าสุด​คือ 1.17.0-beta.1 (ตรวจ nuget.org 2026-07-22) — มัน ยัง​ไม่มี stable release ต่าง​จาก​อีก 5 package ใน​ชุด​ที่ stable ที่ 1.17.0 แล้ว แปล​ว่า API ของ​มัน​อาจ​เปลี่ยน​ก่อน​ถึง 1.0 ถ้า​นโยบาย​ทีม​คุณ​ห้าม prerelease บน production ทาง​เลือก​ที่​ซื่อสัตย์​คือ เขียน span ของ repository เอง (เรา​จะ​สอน​วิธี​สร้าง manual span ในบท3) แล้ว​ค่อย​สลับ​มา​ใช้​ตัว auto เมื่อ​มัน GA เรา​เลือก​ใช้​ตัว beta ใน​คอร์ส​นี้​เพราะ​มัน​ให้ DB span ฟรี — แต่​บอก​ไว้​ตรง​นี้​ว่า​มัน​คือ beta ไม่ใช่​ของ​นิ่ง

ทั้งหมด​อยู่​ใน Program.cs เพิ่ม block เดียว​ก่อน builder.Build() โครง​คือ AddOpenTelemetry() แล้ว​ต่อ .WithTracing(...) กับ .WithMetrics(...) — ใน​บท​นี้​ใส่ เฉพาะ auto-instrumentation (AspNetCore + Http + EFCore + Runtime) ยัง​ไม่มี custom span ปิด​ท้าย​ด้วย .UseOtlpExporter() เพียง​ครั้ง​เดียว ที่​เดินสาย OTLP ให้​ทั้ง​สาม​สัญญาณ:

using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService(serviceName: "ordering-service", serviceVersion: "1.0.0")
.AddAttributes(new Dictionary<string, object>
{ ["deployment.environment"] = builder.Environment.EnvironmentName }))
.WithTracing(t => t
.AddAspNetCoreInstrumentation() // span ฝั่งรับ HTTP request
.AddHttpClientInstrumentation() // span ฝั่งยิง HTTP ออก (เช่น ACL ไป AcmePay)
.AddEntityFrameworkCoreInstrumentation()) // span ของ query DB
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()) // GC, thread pool, memory
.UseOtlpExporter(); // ★ เดียว → OTLP สำหรับ traces + metrics + logs (gRPC http://localhost:4317 เป็น default)
// ★ ยังจำเป็น: UseOtlpExporter() ให้แค่ตัว exporter — บรรทัดนี้ต่างหากที่จับ output ของ ILogger เข้า OTel
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeFormattedMessage = true;
o.IncludeScopes = true;
});

มี​สาม​จุด​ที่​พัง​เงียบ​ได้​ถ้า​พลาด — ปัก​หมุด​ไว้:

  • .UseOtlpExporter() แบบ​ไม่มี argument จะ​อ่าน env var ตระกูล OTEL_EXPORTER_OTLP_* และ default ไป gRPC http://localhost:4317 ซึ่ง​ตรง​กับ port รับ​ของ ClickStack พอดี (zero-config) มัน​ลง​ทะเบียน OTLP ให้ ทั้ง​สาม​สัญญาณ​ใน​คำ​สั่ง​เดียว
  • builder.Logging.AddOpenTelemetry(...) ยัง​ต้อง​มี แม้​จะ​เรียก UseOtlpExporter() แล้ว — เพราะ​ตัว​หลัง​ให้​แค่ ช่อง​ส่ง​ออก ไม่​ได้​ต่อ pipeline ของ ILogger เข้า​มา​ให้ ถ้า​ลืม​บรรทัด​นี้ trace กับ metric จะมา แต่ log จะ​เงียบ
  • ห้าม​ผสม .UseOtlpExporter() กับ .AddOtlpExporter() แบบ​ราย signal (เชื่อ​ว่า​จะ throw) — เลือก อย่าง​ใด​อย่าง​หนึ่ง: จะ​ใช้ UseOtlpExporter() ตัว​เดียว​แบบ​นี้ หรือ​จะ​ใช้ .AddOtlpExporter() สาม​ครั้ง​แยก​ราย signal (แบบ​ที่ block ClickHouse ใช้) ก็ได้ แต่ อย่า​ปน​กัน
🔁 กติกา AddSource/AddMeter ที่​รอ​เรา​อยู่​ใน​บท3–4

สังเกต​ว่า​บท​นี้ ยัง​ไม่มี .AddSource(...) หรือ .AddMeter(...) — เพราะ​เรา​ยัง​ไม่มี ActivitySource/Meter ของ​ตัวเอง auto-instrumentation ลง​ทะเบียน source ของ​มัน​เอง​ให้​แล้ว พอ​ถึง​บท3 (custom span) และ​บท4 (custom metric) เรา​จะ​เพิ่ม AddSource/AddMeter เข้า​มา และ​จะ​ย้ำ​กฎ​เหล็ก: ชื่อ​ต้อง​ตรง​เป๊ะ กับ ActivitySource/Meter ไม่​งั้น telemetry ถูก​ทิ้ง​เงียบ (เช่น meter ของ Wolverine ชื่อ Wolverine:{AppName} ต้อง AddMeter("Wolverine*") — wildcard บังคับ)

จุด​ขาย​ของ OpenTelemetry คือ​ปลายทาง​เป็น​แค่ config ภายนอก ไม่ใช่ code นี่​คือ honesty spine เส้น (A) ที่​จับ​ต้อง​ได้​เป็น​ครั้ง​แรก: OTel คือ​สาย​ไฟ​ที่​พก​พา​ได้ ClickStack คือ​ปลั๊ก​ที่​สลับ​ได้ — code C# ใน​ขั้น​ที่ 3 ไม่มี​คำ​ว่า “ClickStack” หรือ “HyperDX” อยู่​เลย​สัก​ตัว ปลายทาง​กำหนด​ผ่าน env var ล้วนๆ:

Terminal window
OTEL_SERVICE_NAME=ordering-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# production เท่านั้น — OSS ClickStack ต้องแนบ ingestion API key มากับทุก request:
# OTEL_EXPORTER_OTLP_HEADERS='authorization=<YOUR_INGESTION_API_KEY>'

วัน​ไหน​อยาก​สลับ​จาก ClickStack ไป Jaeger, Grafana Tempo, Datadog หรือ Honeycomb — เปลี่ยน​แค่ OTEL_EXPORTER_OTLP_ENDPOINT (กับ header auth ของ​เจ้า​นั้น) code instrument ไม่​ต้อง​แก้​แม้แต่​บรรทัด​เดียว

auth บน localhost — ยัง​ไม่​ฟัน​ธง

เอกสาร​ของ ClickStack ระบุ​ว่า OSS ต้อง​แนบ header authorization=<ingestion API key> (ได้​จาก HyperDX → Team Settings → API Keys) มา​กับ​ทุก OTLP request แต่ สำหรับ all-in-one บน localhost เรา​ยัง​ไม่​ฟัน​ธง​ว่า​มัน​บังคับ header จริง​หรือ​ไม่ — ตัวอย่าง compose ที่​รัน​ใน​เครือข่าย Docker เดียวกัน​ดูเหมือน​จะ​ส่ง​มา​แบบ​ไม่มี header ก็​ยัง​เข้า ฉะนั้น​สำหรับ dev loop บน​เครื่อง​นี้ ลอง​ไม่​ใส่ header ก่อน ถ้า telemetry ไม่​เข้า HyperDX ค่อย​เพิ่ม OTEL_EXPORTER_OTLP_HEADERS ด้วย key ที่​คว้า​มา​ใน​ขั้น​ที่ 1 กฎ​ที่​แน่นอน​คือ: บน production ต้อง​มี key เสมอ — อย่า​ปล่อย endpoint ไว้​เปิด​โล่ง

รัน app Ordering แล้ว​ยิง endpoint สร้าง​ออเดอร์​หนึ่ง​ครั้ง (สมมติ Ordering ฟัง​ที่ :5080):

Terminal window
curl -X POST http://localhost:5080/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"cust-42","items":[{"sku":"pad-thai","qty":1}]}'

กลับ​ไป​ที่ HyperDX ที่ :8080 เปิด​หน้า trace — คุณ​จะ​เห็น trace แรก​เป็น waterfall สอง​ชั้น: server span ของ POST /orders เป็น parent และ​ข้าง​ใต้​มัน child span ของ EF Core ที่​เป็น INSERT ลง​ตาราง orders ทั้ง​คู่​มา​จาก auto-instrumentation ล้วนๆ — เรา​ไม่​ได้​เขียน code trace เอง​เลย

flowchart LR
  subgraph APP["app Ordering (.NET)"]
    EP["server span<br/>POST /orders<br/>(AspNetCore)"]
    EF["child span<br/>INSERT orders<br/>(EF Core)"]
    EP --> EF
  end
  APP -->|"OTLP gRPC :4317"| CS
  subgraph CS["ClickStack (Docker, all-in-one)"]
    COL["OTel Collector distro"]
    CH["ClickHouse<br/>columnar store"]
    HDX["HyperDX UI :8080<br/>trace waterfall"]
    COL --> CH --> HDX
  end
  classDef app fill:#0e7490,stroke:#155e75,color:#f0fdff;
  classDef sink fill:#1e293b,stroke:#0f172a,color:#94a3b8;
  class EP,EF app;
  class COL,CH,HDX sink;

คำ​บรรยาย​ภาพ: auto-instrumentation ให้ waterfall สอง​ชั้น​มา​ฟรี — server span ของ POST /orders ห่อ child span ของ EF Core ที่ INSERT ลง DB code ปล่อย OTLP ผ่าน gRPC port 4317 ไป​เข้า OTel Collector ของ ClickStack ซึ่ง​เขียน​ลง ClickHouse แล้ว HyperDX อ่าน​ออก​มา​เป็น trace ที่​ไล่​ดู​ได้​ทั้ง​เส้น

ขั้น​ที่ 6 — script ยิง​โหลด​ให้​บท​หลัง​มี​สัญญาณ​เล่น

หัวข้อ​ที่​มีชื่อ​ว่า “ขั้น​ที่ 6 — script ยิง​โหลด​ให้​บท​หลัง​มี​สัญญาณ​เล่น”

ออเดอร์​เดียว​ไม่​พอ​ให้​บท6 (สำรวจ) และ​บท7 (alert) มี​อะไร​ดู เตรียม script เล็กๆ ที่​ยิง place-order ซ้ำๆ ด้วย customer และ item หลากหลาย เก็บ​ไว้​เป็น scripts/load.sh:

#!/usr/bin/env bash
# ยิง place-order 200 ครั้ง ให้มี trace/metric/log สะสมไว้ให้บทหลังเล่น
skus=(pad-thai green-curry mango-sticky-rice tom-yum)
for i in $(seq 1 200); do
sku=${skus[$((RANDOM % ${#skus[@]}))]}
curl -s -o /dev/null -X POST http://localhost:5080/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"cust-'"$((RANDOM % 50))"'","items":[{"sku":"'"$sku"'","qty":1}]}'
sleep 0.2
done
echo "ยิงครบ 200 ออเดอร์ — เปิด HyperDX ดูได้เลย"

รัน bash scripts/load.sh ทิ้ง​ไว้​สัก​ครู่ แล้ว HyperDX จะมี trace หลาย​ร้อย​เส้น​ให้​ค้นหา จัด​กลุ่ม และ​ทำ dashboard ในบทถัดๆ ไป — เรา​จะ​กลับ​มา​ใช้ script ตัว​นี้​ตลอด​คอร์ส

ของ​ฟรี​ก้อน​นี้​คือ 'พื้น' ไม่ใช่ 'เพดาน' (honesty spine D)

สิ่ง​ที่​คุณ​เพิ่ง​ได้​มา​น่า​ตื่นเต้น — เปิด​ไฟ​ให้​ระบบ​ก้อน​หนึ่ง​ด้วย​การ​ปัก​ไม่​กี่ package แต่​ต้อง​ซื่อสัตย์​ว่า​มัน​ไป​ได้​แค่​ไหน: auto-instrumentation ให้ span ของ HTTP กับ DB ที่​รู้เรื่อง กลไก (route ไหน, query กี่​มิลลิ​วินาที) แต่​มัน ไม่รู้เรื่อง domain ของ​คุณ​เลย — trace นี้​ไม่รู้​ว่า​นี่​คือ “ออเดอร์​ของ​ลูกค้า​ราย​ไหน”, “ยอด​เท่า​ไหร่”, “จ่าย​เงิน​สำเร็จ​หรือ​ไม่” เพราะ​ข้อมูล​พวก​นั้น​เป็น​ของ คุณ ไม่ใช่​ของ framework

หลักการ​ที่​จะ​กำกับ​ทั้ง​คอร์ส: คุณ debug สิ่ง​ที่​คุณ​ไม่​ได้ instrument ไม่​ได้ auto-instrumentation คือ พื้น (floor) ที่​ได้​มา​ฟรี ไม่ใช่ เพดาน (ceiling) — span/attribute ที่​มี​ความหมาย​เชิง domain (order.id, ผลลัพธ์​การ​จ่าย​เงิน) ต้อง​เขียน​เอง อย่า​เผลอ​ขาย​ว่า “เปิด auto-instrumentation แล้ว​คุณ observable” นั่น​คือ​คำ​โม้ บท3 เริ่ม​เติม​ความหมาย​เชิง domain ที่​หาย​ไป​ก้อน​นี้

บท​นี้​พิสูจน์​ว่า​สายส่ง​สัญญาณ​เชื่อม​ถึงกัน​จริง: รัน clickhouse/clickstack-all-in-one:2.31.0 บน Docker (port 8080/4317/4318/8123), ปัก​ชุด package ที่ pin 1.17.0 — โดย EF Core ยัง​เป็น 1.17.0-beta.1 ที่​ต้อง --prerelease และ​เรา​พูดตรงๆ ว่า​มัน​คือ beta, เดินสาย AddOpenTelemetry() ด้วย auto-instrumentation ล้วน + UseOtlpExporter() เดียว (บวก builder.Logging.AddOpenTelemetry(...) ที่​ยัง​ต้อง​มี), ชี้​ปลายทาง​ด้วย env var — ตอกย้ำ​ว่า OTel คือ​สาย​ไฟ​ที่​สลับ​ปลั๊ก​ได้, แล้ว​เห็น trace แรก (server span → EF Core child) ใน HyperDX สุดท้าย​เตรียม script ยิง​โหลด​ไว้​ให้​บท​หลัง และ​ปัก​หมุด honesty spine (D): auto-instrumentation เป็น​แค่​พื้น

บท​หน้า​เรา​จะ​เริ่ม​เขียน span เอง — ตาม หนึ่ง​ออเดอร์​เป็น trace เดียว ข้าม outbox + RabbitMQ ไป​ถึง Kitchen และ Delivery ที่​ถูก​แยก​ออก​เป็น service เดี่ยว นี่​คือ​บท​ที่​ความ “กระจาย” ของ​ระบบ​กลาย​เป็น​เส้น​เดียว​ที่​ไล่​ดู​ได้​จริง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 2

ข้อ 1 / 3

ทำไม code ในบทนี้ยังต้องมี builder.Logging.AddOpenTelemetry(...) ทั้งที่เรียก .UseOtlpExporter() ไปแล้ว?