1. Foundation và Observability Spine
Foundation và Observability Spine
Giai đoạn 1 xây một server gần như chưa làm gì: nó trả lời hai health check và thực hiện graceful shutdown. Điều quan trọng là ngay từ request đầu tiên, bạn đã thấy được mọi việc server làm. Mỗi request trở thành một trace trong Grafana, mỗi dòng log mang theo trace id, và các metric của Go runtime được vẽ thành biểu đồ. Mọi giai đoạn sau (auth, run, agent loop, tool) sẽ thêm span của mình vào cột sống này thay vì tự phát minh ra cái riêng.
Bạn sẽ học được gì
- Composition root là gì và vì sao hango chỉ có đúng một cái.
- Ba tín hiệu telemetry (trace, log, metric) và cách một id nối chúng lại với nhau.
- Vì sao hango gửi telemetry tới một OpenTelemetry Collector thay vì gửi thẳng tới Grafana và Langfuse.
- Một request đi qua middleware như thế nào, và vì sao một router nhỏ dựa trên thư viện chuẩn là đủ.
- Cách tìm một request trong Grafana, bắt đầu từ một response header.
- Cách debug lớp này khi có thứ không hiện ra.
Trước khi bắt đầu
- Go, Docker với Compose v2 và
make. Các phiên bản được liệt kê trong phần Requirements của README.md. - Tùy chọn: chạy
cp .env.example .envở thư mục gốc của repo trướcmake compose-upnếu bạn muốn thêm key Langfuse. Không có nó thì stack vẫn khởi động, và nhánh Langfuse vẫn tắt.
Các từ dùng trong bài này
| Thuật ngữ | Ý nghĩa |
|---|---|
| Harness | Server bao quanh một LLM: chạy agent loop, gọi tool, lưu trạng thái và áp đặt giới hạn. hango là một harness. |
| Composition root | Nơi duy nhất trong chương trình dựng mọi dependency và nối chúng lại. Trong hango đó là internal/app/app.go. |
| Telemetry | Dữ liệu mà một chương trình tự phát ra về chính nó khi chạy: trace, log và metric. |
| Trace | Bản ghi của một thao tác, ví dụ một HTTP request, dưới dạng cây các span. Nó có một trace id gồm 32 ký tự hex. |
| Span | Một bước có đo thời gian bên trong trace, có tên, thời điểm bắt đầu, kết thúc và các attribute (cặp khóa-giá trị). |
| Metric | Một con số được đo theo thời gian, như thời lượng request hay bộ nhớ đang dùng. |
| OpenTelemetry (OTel) | Chuẩn và SDK trung lập với nhà cung cấp để tạo ra telemetry. |
| OTLP | Giao thức truyền của OpenTelemetry. Chạy trên gRPC (cổng 4317) hoặc HTTP (cổng 4318). |
| Collector | Một tiến trình riêng nhận OTLP, biến đổi nó rồi chuyển tiếp tới một hoặc nhiều backend. |
| Backend | Nơi lưu telemetry kèm giao diện xem. Ở đây: Tempo (trace), Loki (log), Prometheus (metric), tất cả hiển thị trong Grafana, cộng thêm Langfuse cho trace của LLM. |
| Middleware | Một hàm bọc quanh HTTP handler để chạy code trước và sau handler. |
| Liveness / readiness | Liveness hỏi “tiến trình còn sống không?” Readiness hỏi “lúc này nó có nên nhận traffic không?” |
| goclaw | Dự án tham chiếu của khóa học này: một gateway Go multi-tenant chạy thật. Mỗi bài học so sánh hango với nó. |
| deepagents | langchain deepagents, một harness có lõi được ghép từ các middleware. Điểm so sánh thứ hai trong mỗi bài học. |
| LGTM | Loki (log), Grafana (giao diện), Tempo (trace) và một kho metric, ở đây là Prometheus. Image grafana/otel-lgtm chạy cả bốn trong một container. |
| Seam | Chỗ mà thiết kế chủ ý để ngỏ, để bước sau cắm vào mà không phải viết lại code trước đó. |
| SSE | Server-sent events: một HTTP response sống lâu, qua đó server liên tục đẩy sự kiện tới client. |
| SIGTERM | Tín hiệu mà hệ điều hành hoặc Docker gửi để yêu cầu một tiến trình dừng. Ctrl-C gửi SIGINT. |
| errgroup | golang.org/x/sync/errgroup: chạy nhiều goroutine cùng nhau, hủy phần còn lại khi một cái lỗi, và trả về lỗi đầu tiên. |
| distroless | Một container base image tối giản, không có shell và không có package manager, chỉ có những gì chương trình cần để chạy. Dockerfile của hango dùng nó. |
Vì sao observability đến đầu tiên
Một agent harness hỏng theo những cách mà một web server thường không hỏng. Một run có thể lặp vô tận, một tool có thể bị treo, một lần gọi mô hình có thể mất 40 giây, và một tenant có thể làm chậm tenant khác. Khi chuyện đó xảy ra, bạn cần trả lời “chuyện gì đã xảy ra trong request này?” bằng dữ liệu, không phải đoán mò. Nếu thêm tracing ở giai đoạn 10, các giai đoạn 2 đến 9 sẽ được xây trong tình trạng mù rồi phải vá lại. Xây nó trước thì mỗi giai đoạn sau chỉ việc thêm span vào một đường ống đã chạy tốt.
Phiên bản nhỏ nhất
Trước khi vào code thật, đây là toàn bộ ý tưởng trong một file. Nó khởi động một tracer, bọc một handler bằng otelhttp và in ra trace id.
package main
import (
"context"
"log"
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
func main() {
exp, err := otlptracehttp.New(context.Background()) // sends to localhost:4318
if err != nil {
log.Fatal(err)
}
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
defer tp.Shutdown(context.Background())
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
id := trace.SpanFromContext(r.Context()).SpanContext().TraceID()
w.Write([]byte("trace " + id.String() + "\n"))
})
handler := otelhttp.NewHandler(mux, "hello", otelhttp.WithTracerProvider(tp))
log.Fatal(http.ListenAndServe(":8080", handler))
}Mọi thứ còn lại của giai đoạn 1 đều lớn lên từ đây: log mang cùng một id, metric nằm cạnh trace, graceful shutdown để flush nốt batch, và một collector đứng giữa ứng dụng và các backend.
Cách hango được nối dây
Composition root
cmd/hango/main.go chỉ phân tích flag, biến SIGINT và SIGTERM thành một context bị hủy bằng signal.NotifyContext, rồi gọi app.Run. internal/app/app.go dựng mọi thứ theo thứ tự:
config.Load -> telemetry.Init -> server.New -> errgroup runs server.Start -> wait for ctxKhi shutdown thì chạy ngược lại. Server drain trước, rồi telemetry flush nốt, nên span và log của những request cuối vẫn rời khỏi tiến trình. Không package nào khác tự dựng dependency của riêng mình. Khi một giai đoạn sau thêm database pool hay run worker, nó được tạo trong app.go rồi truyền vào. Đọc một file là biết chương trình được làm từ những gì.
Cấu hình
internal/config chỉ đọc biến môi trường, không có file cấu hình và không hot reload. Nó gom hết mọi giá trị sai rồi mới báo lỗi, để bạn sửa một lượt. Đây là output thật:
$ HANGO_ENV=staging HANGO_SHUTDOWN_TIMEOUT=soon OTEL_EXPORTER_OTLP_PROTOCOL=grpc go run ./cmd/hango
hango: invalid configuration:
HANGO_ENV="staging": must be one of dev, prod
HANGO_SHUTDOWN_TIMEOUT="soon": must be a positive duration such as 15s
OTEL_EXPORTER_OTLP_PROTOCOL="grpc": must be one of http/protobufTelemetry
telemetry.Init tạo mỗi loại provider một cái và cài chúng làm toàn cục:
- Một
TracerProvidervới batch exporter. Sampler dựa theo parent: nếu request đến đã mang một trace được lấy mẫu, hango tiếp tục nó, còn không thì giữHANGO_OTEL_SAMPLE_RATIOsố trace mới. - Một
MeterProvidervới periodic reader. Các metric của Go runtime bắt đầu từ đây. - Một
LoggerProviderđứng sau cầu nốiotelslog.
Logger slog mặc định ghi ra hai nơi: stdout, và cầu nối otelslog chuyển log tới collector. Có stdout nghĩa là bạn vẫn đọc được log khi collector chết. Cả hai đầu ra đều mang trace_id và span_id, nên một dòng trên terminal dẫn bạn tới trace của nó trong Grafana.
Với HANGO_OTEL_ENABLED=false (hoặc OTEL_SDK_DISABLED=true theo chuẩn), Init trả về các provider no-op. Code tạo span không đổi, và không có gì được xuất ra.
Tên do hango sở hữu
Ba file giữ các tên dưới dạng hằng số thay vì chuỗi literal rải rác khắp code:
| File | Chứa | Vì sao |
|---|---|---|
attrs.go, genai.go, langfuse.go | Các khóa attribute (hango.request.id, gen_ai.request.model, langfuse.observation.input, …) | Quy ước GenAI vẫn đang thay đổi ở upstream. Chép về cục bộ nghĩa là nâng cấp SDK không thể đổi tên chúng dưới chân ta. |
metrics_names.go | Mọi tên metric mà hango đăng ký | Dashboard query metric theo tên. Gõ sai sẽ tạo ra một metric mới và dashboard trống trơn mà không báo lỗi. Một test đọc mọi file Go và báo lỗi nếu có metric được tạo bằng chuỗi literal. |
content_attrs.go | Mọi attribute có thể chứa nội dung prompt, completion hoặc tool | Các khóa này phải bị xóa trước khi dữ liệu tới Grafana. Logger bỏ chúng khỏi mọi bản ghi log, kể cả bên trong group, và collector lại xóa lần nữa như lớp chặn phía sau. Một test kiểm tra rằng các câu lệnh xóa của collector chính là những câu được sinh ra từ danh sách này. |
Dashboard và việc che dữ liệu đều hỏng mà không báo lỗi khi một cái tên bị lệch. Danh sách do hango sở hữu cộng với test biến sự lệch âm thầm đó thành một bản build thất bại.
Vì sao cần collector
hango gửi toàn bộ telemetry tới một nơi, là collector ở OTEL_EXPORTER_OTLP_ENDPOINT, rồi collector chia ra:
hango --OTLP/HTTP--> otelcol --+-- traces --> Langfuse
+-- traces --> Tempo (content stripped)
+-- metrics --> Prometheus
+-- logs --> Loki (content stripped)Việc xuất thẳng từ ứng dụng tới cả hai backend bị loại bỏ vì ba lý do:
- Langfuse chỉ nhận OTLP qua HTTP, không qua gRPC. Với hai exporter trong cùng tiến trình, ứng dụng sẽ cần hai cấu hình giao thức và hai hàng đợi batch.
- Việc xóa nội dung prompt là một quy tắc trong
deploy/otelcol/config.yaml. Đổi nó chỉ cần khởi động lại collector, không cần deploy lại hango. - Collector có hàng đợi và tự retry. Một lần Langfuse ngừng hoạt động không làm đầy bộ nhớ của hango.
Router
hango dùng http.ServeMux của thư viện chuẩn cộng với một router nhỏ của riêng mình (internal/server/router.go). Từ Go 1.22, mux đã khớp được method và tham số trên đường dẫn ("GET /v1/things/{id}"), điều này loại bỏ lý do chính để dùng framework. Những gì các framework như Echo, Gin, chi và Fiber thêm vào, và cách hango đáp ứng:
| Tính năng của framework | hango |
|---|---|
| Handler trả về lỗi, render lỗi tập trung | HandlerFunc trả về error. *server.Error giữ nguyên status, còn mọi lỗi khác trở thành 500 với một thông báo cố định. |
| Bind JSON | DecodeJSON: giới hạn 1 MiB, từ chối trường lạ, từ chối dữ liệu thừa phía sau. |
| Nhóm route kèm middleware | Router.Group(mw...) |
| Middleware dựng sẵn | Request id, phục hồi panic, log request, và otelhttp của OpenTelemetry. |
Fiber thì hoàn toàn không thể dùng. Nó xây trên fasthttp, không phải net/http, nên otelhttp, việc hủy theo request-context và server-sent event (giai đoạn 6) đều phải thay thế. goclaw, một harness Go chạy thật được dùng làm tham chiếu xuyên suốt khóa học này, cũng phục vụ toàn bộ API từ http.ServeMux theo cách tương tự (internal/http).
Chuỗi middleware
Lớp ngoài cùng đứng trước:
| Lớp | Việc |
|---|---|
otelhttp | Bắt đầu span phía server. Sau khi mux chạy xong, nó đặt tên span là GET /readyz từ pattern đã khớp và thêm http.route vào metric thời lượng. Bỏ qua /healthz. Coi mọi bên gọi là không đáng tin: một traceparent đến chỉ trở thành span link, nên client không thể chọn trace id hay ép lấy mẫu. |
recordStatus | Bọc response writer để code phía sau đọc được status. Nó cài Unwrap, nên http.ResponseController(w).Flush() vẫn chạm tới writer thật. |
requestID | Giữ lại X-Request-Id an toàn của client hoặc tạo một UUIDv7. Đặt nó lên span, vào log context và vào response, cùng với X-Trace-Id. |
logRequests | Một dòng log request cho mỗi request: method, route, status, thời lượng. |
recoverPanics | Một panic trở thành lỗi 500 có log và trace thay vì một kết nối bị rớt. |
Một lỗi tìm thấy khi test cho thấy vì sao thứ tự và chi tiết quan trọng. Lần chạy test đầu tiên in ra:
--- FAIL: TestOneServerSpanPerRequestNamedByRoute (0.00s)
server_test.go:91: span "GET" kind server
--- FAIL: TestRequestDurationMetricCarriesRoute (0.00s)
server_test.go:119: http.server.request.duration has no http.route=/v1/things/{id} data pointotelhttp đọc r.Pattern sau khi handler trả về. Nhưng requestID gọi r.WithContext(ctx), hàm này trả về một bản sao của request. Mux đặt Pattern lên bản sao đó, còn otelhttp vẫn cầm bản gốc với Pattern rỗng. Cách sửa là ba dòng trong requestID: sau next.ServeHTTP, chép inner.Pattern ngược lại vào r.Pattern. Bất kỳ middleware nào thay thế request đều gặp vấn đề này, nên hãy nhớ khi bạn viết một cái.
Liveness, readiness và shutdown
GET /healthz luôn trả 200 khi tiến trình còn chạy. Các orchestrator gọi nó vài giây một lần, nên nó không được trace hay log. GET /readyz chạy đồng thời mọi check đã đăng ký, mỗi check có timeout 2 giây, và trả 503 nếu có check nào lỗi. Response gọi tên check lỗi bằng "fail", còn lỗi thật được ghi vào log, vì những lỗi như dial tcp 10.0.0.5:5432 mô tả mạng nội bộ. Một check bỏ qua context của nó sẽ bị bỏ lại khi hết timeout. Giai đoạn 2 đăng ký check cho database.
Khi nhận SIGTERM, server:
- Đánh dấu mình đang drain, nên
/readyztrả 503 và các load balancer ngừng gửi traffic. - Tiếp tục phục vụ thêm 1 giây để chúng kịp nhận ra.
- Gọi
http.Server.Shutdown, chờ các request đang chạy cho tới hạn shutdown trừ đi một khoảng dự trữ ngắn cho telemetry (2 giây, hoặc một phần tư của ngân sách ngắn). - Trả về, và
app.Runflush telemetry cho tới hạn shutdown.
HANGO_SHUTDOWN_TIMEOUT là tổng ngân sách. app.Run lấy một mốc hạn duy nhất vào lúc tín hiệu đến, và độ trễ drain, việc drain server và việc flush telemetry đều đo theo mốc đó, nên tiến trình thoát trong vòng một timeout.
Output thật từ docker compose stop hango, thoát với mã 0 sau khoảng 1 giây:
level=INFO msg="draining: readiness now failing" delay=1s budget=13s
level=INFO msg="http server stopped"
level=INFO msg="hango stopped"Trace và metric bị giới hạn ở hai phần ba thời gian còn lại, nên log luôn còn thời gian và một trace exporter bị kẹt không thể ngăn các dòng log cuối cùng đi ra. Compose đặt stop_grace_period: 20s, tức ngân sách 15 giây cộng thêm dư, vì mặc định 10 giây của Docker sẽ giết tiến trình giữa lúc đang flush. Ctrl-C lần thứ hai thoát ngay lập tức.
WriteTimeout cố ý để 0. Một write timeout toàn cục sẽ cắt mọi luồng server-sent event ở giai đoạn 6 tại một độ tuổi cố định.
Cách xem server đang làm gì
Khởi động stack và gửi một request:
make compose-up curl -i localhost:8080/readyzResponse thật:
HTTP/1.1 200 OK Content-Type: application/json X-Request-Id: 01a13cf0-9c8e-726a-aa04-4f57a4175f2e X-Trace-Id: ec55eb5d66c12d5222b23dbb1154302b {"checks":{},"ready":true}Chép giá trị
X-Trace-Id.Mở Grafana tại localhost:3000/explore và đăng nhập bằng admin/admin. Chọn data source Tempo, chọn tab TraceQL, dán trace id và chạy query. Bạn sẽ thấy một span duy nhất,
GET /readyz, loại server. Các attribute của nó gồmhttp.response.status_code,url.pathvàhango.request.id.Đổi data source sang Loki và chạy:
{service_name="hango"} | trace_id="ec55eb5d66c12d5222b23dbb1154302b"Bạn nhận được dòng log
requestcủa request đó, vớihango_request_idkhớp headerX-Request-Id.Đổi sang Prometheus và chạy
go_memory_used_byteshoặcgo_goroutine_countđể xem metric runtime. Để xem độ trễ p95 theo từng route:histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket[5m])))
Trong compose, metric được xuất mỗi 10 giây (OTEL_METRIC_EXPORT_INTERVAL=10000), nên hãy chờ chừng đó rồi mới mong thấy điểm dữ liệu mới.
Cách debug lớp này
Server không khởi động
Lỗi cấu hình liệt kê mọi biến sai, như đã thấy ở trên. Sửa hết rồi khởi động lại. Nếu lỗi là address already in use, có tiến trình khác đang giữ cổng 8080: tìm nó bằng lsof -i :8080.
Server chạy nhưng không có gì hiện trong Grafana
Kiểm tra dòng khởi động. Nó in
otel_enabledvàotlp_endpoint. Trong compose, endpoint phải làhttp://otelcol:4318. Trên máy host thì làhttp://localhost:4318.Kiểm tra collector:
docker compose logs otelcol. Nó phải inEverything is ready. Lỗi kết nối tớilgtm:4317ngay sau khi khởi động nghĩa là lgtm vẫn đang boot. Compose chờ lgtm ở trạng thái healthy rồi mới khởi động collector.Các cảnh báo của collector đáng để đọc. Collector 0.161 in các dòng sau khi cấu hình dùng tên exporter cũ:
warn "otlp" alias is deprecated; use "otlp_grpc" instead warn "otlphttp" alias is deprecated; use "otlp_http" insteadCác exporter trong
deploy/otelcol/config.yamlđược đặt tên làotlp_grpc/lgtmvàotlp_http/langfuse.Metric cần tối đa một khoảng xuất để tới nơi. Trace và log được gom batch và tới trong vài giây.
Trace hiện trong Tempo nhưng không có trong Langfuse
Trước hết kiểm tra LANGFUSE_PUBLIC_KEY đã được đặt trong .env chưa. Khi nó rỗng, nhánh Langfuse bị tắt: filter/langfuse_disabled trong cấu hình collector bỏ mọi span trên nhánh đó, nên không có gì rời đi tới Langfuse và không có lỗi nào được log. Việc này giữ cho trace, và sau này là nội dung prompt, không chạy sang một bên thứ ba mà bạn chưa thiết lập.
Với một key mà Langfuse từ chối, collector log như sau, còn các nhánh Tempo, Loki và Prometheus vẫn hoạt động:
error Exporting failed. Dropping data. "otelcol.component.id": "otlp_http/langfuse"
"error": "not retryable error: Permanent error: rpc error: code = Unauthenticated desc = error exporting items,
request to https://cloud.langfuse.com/api/public/otel/v1/traces responded with HTTP Status Code 401"Mã 401 nghĩa là public key hoặc secret key sai. Đặt LANGFUSE_PUBLIC_KEY và LANGFUSE_SECRET_KEY vào .env rồi chạy docker compose up -d --force-recreate otelcol. Lệnh restart thường không đọc lại .env. Nếu trace tới nhưng hiện trễ vài phút, kiểm tra header x-langfuse-ingestion-version: "4" vẫn còn trong cấu hình exporter.
Đánh đổi
| Lựa chọn | Phương án khác | Vì sao chọn cái này lúc này |
|---|---|---|
grafana/otel-lgtm, một container | Tempo, Loki, Prometheus và Grafana tách riêng | Một container là đủ cho phát triển. Nó không đủ cho production. Collector cũng có thể gửi mọi thứ tới Grafana Cloud, xem observability-cloud-setup.md. |
| Langfuse Cloud Hobby | Langfuse tự host (deploy/langfuse/compose.langfuse.yaml) | Tự host cần sáu service và khoảng 4 core, 16 GiB. Cloud Hobby miễn phí ở quy mô này. Chỉ tự host nếu nội dung prompt phải ở lại máy mình. |
| Sample ratio 1.0 | Giữ một phần trace | Trace đầy đủ không tốn gì khi phát triển. Ở production, hãy hạ HANGO_OTEL_SAMPLE_RATIO. Span con theo quyết định của span cha, nên trace không bao giờ bị giữ một nửa. |
goclaw và deepagents làm việc này thế nào
goclaw đặt phần khởi động trong một thư mục cmd/ hơn 100 file, nhiều file trong số đó là các file nối dây gateway_*. Nó chỉ có một endpoint /health, đọc cấu hình JSON5 và hot reload bằng fsnotify. Tracing của nó tự làm: span được đệm trong bộ nhớ rồi flush vào các bảng traces và spans trong database riêng, còn việc xuất OTLP có sẵn sau một build tag.
langchain deepagents không phải là một server. Việc ghép của nó diễn ra trong create_deep_agent, hàm này lắp một danh sách middleware có thứ tự rồi đưa cho agent loop của LangChain. Cùng một ý tưởng với composition root của hango, một constructor sở hữu thứ tự, nhưng ở một tầng khác. Tracing đến từ LangSmith qua các callback của LangChain chứ không phải OpenTelemetry.
hango khác biệt có chủ đích:
- Một file nối dây thay vì nhiều file, để đọc được cả chương trình một lượt.
- Hai health endpoint, vì “còn sống” và “sẵn sàng nhận traffic” là hai câu hỏi khác nhau khi shutdown và khi database sập.
- Không hot reload. Đổi cấu hình nghĩa là khởi động lại, và chuỗi drain làm điều đó an toàn.
- OpenTelemetry chuẩn thay vì kho span tự làm, nên backend OTel nào cũng dùng được và hango không lưu hay query span.
Bài tập
- Thêm một giá trị cấu hình từ đầu đến cuối. Thêm
HANGO_DRAIN_DELAY(một duration, mặc định1s) vàointernal/config, test rằng giá trị sai được báo lỗi, và dùng nó thay cho hằng sốdrainDelaytronginternal/server/server.go. - Làm cho
/readyzthất bại và xem quá trình drain. Trongapp.go, đăng ký một check bằngsrv.Health().Register("demo", func(context.Context) error { return errors.New("not yet") }). Dùng curl gọi/readyzvà đọc body 503. Tìm trace trong Tempo, rồi gửi SIGTERM và đọc các dòng log drain. - Tắt telemetry. Chạy
HANGO_OTEL_ENABLED=false go run ./cmd/hangokhi không có collector nào chạy. Xác nhận server khởi động, trả lời/readyzvới cả hai header, và thực hiện graceful shutdown. Lưu ýX-Trace-Idbị thiếu: hãy tự suy ra vì sao từrequestID. - Phá thử lớp bảo vệ nội dung. Thêm một khóa vào
ContentAttrstronginternal/telemetry/content_attrs.go, chạygo test ./internal/telemetry, đọc lỗi, rồi sửa bằng flag-updatemà thông báo gợi ý.