Chuyển đến nội dung
1. Foundation và Observability Spine

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ước make compose-up nế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
HarnessServer 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 rootNơ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.
TelemetryDữ liệu mà một chương trình tự phát ra về chính nó khi chạy: trace, log và metric.
TraceBả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.
SpanMộ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ị).
MetricMộ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.
OTLPGiao thức truyền của OpenTelemetry. Chạy trên gRPC (cổng 4317) hoặc HTTP (cổng 4318).
CollectorMộ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.
BackendNơ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.
MiddlewareMột hàm bọc quanh HTTP handler để chạy code trước và sau handler.
Liveness / readinessLiveness 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?”
goclawDự á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ó.
deepagentslangchain 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.
LGTMLoki (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.
SeamChỗ 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 đó.
SSEServer-sent events: một HTTP response sống lâu, qua đó server liên tục đẩy sự kiện tới client.
SIGTERMTí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.
errgroupgolang.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.
distrolessMộ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 ctx

Khi 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/protobuf

Telemetry

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 TracerProvider vớ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_RATIO số trace mới.
  • Một MeterProvider với periodic reader. Các metric của Go runtime bắt đầu từ đây.
  • Một LoggerProvider đứng sau cầu nối otelslog.

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:

FileChứaVì sao
attrs.go, genai.go, langfuse.goCá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.goMọ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.goMọi attribute có thể chứa nội dung prompt, completion hoặc toolCá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:

  1. 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.
  2. 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.
  3. 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 frameworkhango
Handler trả về lỗi, render lỗi tập trungHandlerFunc 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 JSONDecodeJSON: 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 middlewareRouter.Group(mw...)
Middleware dựng sẵnRequest 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ớpViệc
otelhttpBắ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.
recordStatusBọ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.
requestIDGiữ 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.
logRequestsMột dòng log request cho mỗi request: method, route, status, thời lượng.
recoverPanicsMộ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 point

otelhttp đọ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:

  1. Đánh dấu mình đang drain, nên /readyz trả 503 và các load balancer ngừng gửi traffic.
  2. Tiếp tục phục vụ thêm 1 giây để chúng kịp nhận ra.
  3. 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).
  4. Trả về, và app.Run flush 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ì

  1. Khởi động stack và gửi một request:

    make compose-up
    curl -i localhost:8080/readyz

    Response 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}
  2. Chép giá trị X-Trace-Id.

  3. 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ồm http.response.status_code, url.path và hango.request.id.

  4. Đổi data source sang Loki và chạy:

    {service_name="hango"} | trace_id="ec55eb5d66c12d5222b23dbb1154302b"

    Bạn nhận được dòng log request của request đó, với hango_request_id khớp header X-Request-Id.

  5. Đổi sang Prometheus và chạy go_memory_used_bytes hoặc go_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

  1. Kiểm tra dòng khởi động. Nó in otel_enabled và otlp_endpoint. Trong compose, endpoint phải là http://otelcol:4318. Trên máy host thì là http://localhost:4318.

  2. Kiểm tra collector: docker compose logs otelcol. Nó phải in Everything is ready. Lỗi kết nối tới lgtm:4317 ngay 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.

  3. 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" instead

    Các exporter trong deploy/otelcol/config.yaml được đặt tên là otlp_grpc/lgtm và otlp_http/langfuse.

  4. 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ọnPhương án khácVì sao chọn cái này lúc này
grafana/otel-lgtm, một containerTempo, Loki, Prometheus và Grafana tách riêngMộ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 HobbyLangfuse 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.0Giữ một phần traceTrace đầ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

  1. 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 định 1s) vào internal/config, test rằng giá trị sai được báo lỗi, và dùng nó thay cho hằng số drainDelay trong internal/server/server.go.
  2. Làm cho /readyz thất bại và xem quá trình drain. Trong app.go, đăng ký một check bằng srv.Health().Register("demo", func(context.Context) error { return errors.New("not yet") }). Dùng curl gọi /readyz và đọc body 503. Tìm trace trong Tempo, rồi gửi SIGTERM và đọc các dòng log drain.
  3. Tắt telemetry. Chạy HANGO_OTEL_ENABLED=false go run ./cmd/hango khi không có collector nào chạy. Xác nhận server khởi động, trả lời /readyz với cả hai header, và thực hiện graceful shutdown. Lưu ý X-Trace-Id bị thiếu: hãy tự suy ra vì sao từ requestID.
  4. Phá thử lớp bảo vệ nội dung. Thêm một khóa vào ContentAttrs trong internal/telemetry/content_attrs.go, chạy go test ./internal/telemetry, đọc lỗi, rồi sửa bằng flag -update mà thông báo gợi ý.