Chuyển đến nội dung
Nền tảng, khung server và xương sống observability

Nền tảng, khung server và xương sống observability

Tổng quan

Tạo Go module, một binary duy nhất, composition root (nơi duy nhất lắp ráp mọi thành phần), cấu hình qua biến môi trường, server net/http của thư viện chuẩn với các endpoint health, tắt server êm (graceful shutdown) theo tín hiệu, và bộ công cụ chạy local. Điểm quan trọng nhất: observability là nền tảng, không phải một giai đoạn làm sau. Giai đoạn này xây internal/telemetry (một TracerProvider, một MeterProvider, một LoggerProvider, với bridge otelslog được cài làm handler slog mặc định), metric của Go runtime, xử lý HTTP bọc bằng otelhttp, các hằng số attribute của hango, các hằng số GenAI semconv khai báo cục bộ, và một compose.yaml đẩy dữ liệu qua OpenTelemetry Collector tới Grafana LGTM và Langfuse. Từ Phase 1 trở đi, mọi dòng log đều mang trace_id/span_id, và mọi giai đoạn sau đều tự gắn instrumentation cho phần của mình.

Yêu cầu

Chức năng

  • cmd/hango build ra một binary khởi động HTTP server tại địa chỉ đã cấu hình.
  • GET /healthz luôn trả 200; GET /readyz chạy các check đã đăng ký và báo draining khi quá trình shutdown bắt đầu.
  • Cấu hình đọc từ biến môi trường với giá trị mặc định có tài liệu; giá trị bắt buộc bị thiếu hoặc sai sẽ làm khởi động thất bại và liệt kê toàn bộ biến lỗi.
  • SIGINT/SIGTERM kích hoạt graceful shutdown trong một thời hạn giới hạn, bao gồm cả việc flush telemetry provider.
  • Mỗi request có một request id; response mang X-Request-Id và X-Trace-Id để người dùng nhảy thẳng từ response sang trace.
  • internal/telemetry.Init(ctx, cfg) trả về Shutdown(ctx) error để flush trace, metric và log.
  • docker compose up khởi động hango, postgres, otelcol và lgtm; Grafana tại localhost:3000 hiển thị trace, log và metric runtime của server.

Phi chức năng

  • Không dùng thư viện bên thứ ba cho config, logging hay DI: dùng helper env của stdlib, log/slog với OTel bridge.
  • Một đường xuất OTLP duy nhất (http/protobuf tới collector) cho cả ba loại tín hiệu; app không bao giờ nói chuyện trực tiếp với Langfuse.
  • Nội dung prompt và completion không bao giờ tới Tempo hay Loki: việc loại bỏ xảy ra ở collector, không phải ở app.
  • Tên attribute của GenAI semantic convention được khai báo thành hằng số cục bộ của hango; không phụ thuộc vào package semconv sinh tự động (GenAI semconv đang ở trạng thái Development và đã chuyển repository một lần).
  • Mọi tên metric do bất kỳ giai đoạn nào phát ra đều là một hằng số trong `internal/telemetry/metrics_names.go`, được đưa vào từ giai đoạn này. Giai đoạn nào phát metric mà không thêm hằng số tương ứng là lỗi; Phase 14 dùng danh sách này thay vì vá lại sau.
  • Tập các key attribute được phép mang nội dung prompt, completion, tool-input hay tool-output là một danh sách hằng số do hango sở hữu trong `internal/telemetry/content_attrs.go`. Collector loại bỏ đúng danh sách đó; app không bao giờ đặt một content attribute mà key không có trong danh sách.
  • Image cuối là gcr.io/distroless/static-debian12:nonroot, CGO_ENABLED=0, -trimpath, version được đóng dấu qua -ldflags -X.
  • golangci-lint run sạch với bộ linter nền tảng v2.

Kiến trúc

Composition root: app.Run(ctx) dựng config, rồi telemetry, rồi logger, rồi readiness registry, rồi server, sau đó chặn chờ trên errgroup. Không nơi nào khác được tạo dependency.

// internal/telemetry/telemetry.go
type Config struct {
	ServiceName    string // hango
	ServiceVersion string // build stamp
	Environment    string // deployment.environment.name
	OTLPEndpoint   string // http://otelcol:4318
	Enabled        bool   // false -> no-op providers, everything still compiles and runs
	SampleRatio    float64
}

type Telemetry struct {
	Tracer trace.Tracer
	Meter  metric.Meter
	Logger *slog.Logger // otelslog bridge; also installed via slog.SetDefault
}

func Init(ctx context.Context, cfg Config) (*Telemetry, func(context.Context) error, error)

// internal/telemetry/attrs.go — hango-owned attribute keys
const (
	AttrTenantID  = "hango.tenant.id"
	AttrAgentID   = "hango.agent.id"
	AttrSessionID = "hango.session.id"
	AttrRunID     = "hango.run.id"
	AttrTurn      = "hango.turn"
	AttrToolName  = "hango.tool.name"
	AttrLane      = "hango.lane"
	// Red team: F13 — per-tenant LLM-content opt-in; set from tenants.trace_llm_content (phase 2)
	AttrTenantTraceLLMContent = "hango.tenant.trace_llm_content"
)

// internal/telemetry/genai.go — locally declared, NOT imported from a generated package
const (
	GenAIOperationName = "gen_ai.operation.name" // chat | invoke_agent | execute_tool
	GenAIProviderName  = "gen_ai.provider.name"
	GenAISystem        = "gen_ai.system"         // older name Langfuse still documents; emit both
	GenAIRequestModel  = "gen_ai.request.model"
	GenAIResponseModel = "gen_ai.response.model"
	GenAIFinishReasons = "gen_ai.response.finish_reasons"
	GenAIUsageInput    = "gen_ai.usage.input_tokens"
	GenAIUsageOutput   = "gen_ai.usage.output_tokens"
	GenAIUsageCacheRead  = "gen_ai.usage.cache_read.input_tokens"
	GenAIUsageCacheWrite = "gen_ai.usage.cache_write.input_tokens"
	GenAITimeToFirstChunk = "gen_ai.response.time_to_first_chunk"
	GenAIConversationID   = "gen_ai.conversation.id"
	GenAIAgentName        = "gen_ai.agent.name"
	GenAIToolName         = "gen_ai.tool.name"
	GenAIToolCallID       = "gen_ai.tool.call.id"
	// Red team: F13 — used by phase 3 onward; previously referenced without being declared
	GenAIRequestMaxTokens   = "gen_ai.request.max_tokens"
	GenAIRequestTemperature = "gen_ai.request.temperature"
	GenAIRequestStream      = "gen_ai.request.stream"
	GenAIResponseID         = "gen_ai.response.id"
	GenAIUsageReasoning     = "gen_ai.usage.reasoning.output_tokens"
)

// internal/telemetry/langfuse.go — langfuse.* always wins over generic conventions
const (
	LFTraceName      = "langfuse.trace.name"
	LFTraceTags      = "langfuse.trace.tags"
	LFTraceMetaPfx   = "langfuse.trace.metadata."
	LFSessionID      = "langfuse.session.id"
	LFUserID         = "langfuse.user.id"
	LFEnvironment    = "langfuse.environment"
	LFObsType        = "langfuse.observation.type" // span|generation|event|agent|tool|chain|...
	LFObsInput       = "langfuse.observation.input"
	LFObsOutput      = "langfuse.observation.output"
	LFObsMetaPfx     = "langfuse.observation.metadata."
	LFObsUsageDetail = "langfuse.observation.usage_details"
	LFObsCostDetail  = "langfuse.observation.cost_details"
	LFObsModelName   = "langfuse.observation.model.name"
	LFObsLevel       = "langfuse.observation.level"
	LFObsStatusMsg   = "langfuse.observation.status_message"
	LFObsCompletionStart = "langfuse.observation.completion_start_time"
)

// internal/config/config.go
type Config struct {
	Env             string // dev|prod
	HTTPAddr        string
	LogLevel        slog.Level
	ShutdownTimeout time.Duration
	Version         string
	Telemetry       telemetry.Config
}

func Load() (Config, error)

// internal/server/server.go
type Server struct{ /* mux, cfg, log, health */ }

func New(cfg config.Config, log *slog.Logger, tel *telemetry.Telemetry) *Server
func (s *Server) Start(ctx context.Context) error
func (s *Server) Health() *Health

// internal/server/router.go
// A handler returns an error; one place turns errors into status codes and JSON bodies.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
type Middleware func(http.Handler) http.Handler

type Router struct{ /* *http.ServeMux, middleware for this group */ }

func (rt *Router) Handle(pattern string, h HandlerFunc) // pattern like "POST /v1/runs/{id}/cancel"
func (rt *Router) Group(mw ...Middleware) *Router     // same mux, extra middleware
func DecodeJSON(w http.ResponseWriter, r *http.Request, v any) error // MaxBytesReader, DisallowUnknownFields

// internal/server/health.go
type Check func(ctx context.Context) error

func (h *Health) Register(name string, c Check)
func (h *Health) MarkDraining()
func (h *Health) Ready(ctx context.Context) (ok bool, details map[string]string)

Instrumentation

  • Span: mỗi HTTP request có một span SERVER từ otelhttp.NewHandler bọc quanh mux, tên span được router đặt thành {method} {route pattern} lấy từ r.Pattern để cardinality luôn bị giới hạn.
  • Attribute trên mọi request span và log record: hango.request.id, cộng thêm hango.tenant.id từ Phase 2 trở đi.
  • Metric: contrib/instrumentation/runtime (runtime.Start) cho các series của Go runtime; http.server.request.duration từ otelhttp. otelhttp bọc mux và chạy trước khi routing, nên nó không thấy route. Router thêm http.route (phần path của r.Pattern) vào metric labeler qua otelhttp.LabelerFromContext(r.Context()).Add(...) sau khi routing; thiếu bước này thì dashboard API theo route của Phase 14 không thể dựng được. Mọi tên metric lấy từ internal/telemetry/metrics_names.go.
  • Log: bridge otelslog được đặt làm handler slog mặc định, nên các record slog.InfoContext(ctx, ...) mang trace id và span id đang hoạt động vào Loki một cách tự động. Request health-check được loại khỏi tracing bằng một otelhttp.WithFilter.
  • Collector fan-out: trace đi tới Langfuse (otlphttp với basic auth + x-langfuse-ingestion-version: 4) và tới lgtm; metric và log đi tới lgtm. Việc loại bỏ nội dung dựa trên danh sách hằng số do hango sở hữu trong internal/telemetry/content_attrs.go (prompt, completion, input/output messages, system instructions, langfuse.observation.input|output, và các key attribute input/output của tool), áp dụng vô điều kiện trên các pipeline lgtm và có điều kiện trên pipeline Langfuse cho các span mang hango.tenant.trace_llm_content=false. Danh sách này được sinh vào config của collector, nên việc thêm một content attribute mà không thêm vào danh sách sẽ bị test của Phase 3 phát hiện.

Giai đoạn này không có bảng nào.

Các file liên quan

Tạo mới

  • go.mod, go.sum — module github.com/phihdn/hango, Go 1.27.
  • cmd/hango/main.go — flag (-version), signal.NotifyContext, gọi app.Run.
  • internal/app/app.go — file lắp ráp duy nhất.
  • internal/config/config.go, internal/config/config_test.go.
  • internal/telemetry/telemetry.go, internal/telemetry/attrs.go, internal/telemetry/genai.go, internal/telemetry/langfuse.go, internal/telemetry/noop.go, internal/telemetry/telemetry_test.go.
  • `internal/telemetry/metrics_names.go` (mọi tên metric của hango dưới dạng hằng số; các Phase 2-16 thêm tên của mình vào đây trước tiên) và `internal/telemetry/content_attrs.go` (danh sách key content attribute cùng helper `IsContentAttr(key) bool`), mỗi file có một test.
  • internal/server/server.go, internal/server/health.go, internal/server/router.go (Handle, Group, error adapter, DecodeJSON, response writer ghi nhận status), internal/server/middleware.go (request id, recover), internal/server/router_test.go, internal/server/server_test.go, internal/server/health_test.go.
  • Makefile, .golangci.yml, Dockerfile, compose.yaml, deploy/otelcol/config.yaml, deploy/langfuse/compose.langfuse.yaml (có tài liệu, gắn profile, không khởi động mặc định).
  • .env.example, .dockerignore, .gitignore.
  • README.md, docs/system-architecture.md (khung sườn: Overview, Packages, Data model, Request flows, Run lifecycle, Tenancy, Observability, Deployment), docs/learning/README.md, docs/learning/01-foundation-and-observability-spine.md.

Sửa đổi

  • Không có (greenfield).

Các bước triển khai

  1. go mod init github.com/phihdn/hango; go 1.27, toolchain go1.27.1. Thêm go.opentelemetry.io/otel + /sdk + /sdk/metric v1.46.0, go.opentelemetry.io/otel/sdk/log v0.22.0, otlptracehttp/otlpmetrichttp/otlploghttp, contrib/bridges/otelslog v0.20.1, contrib/instrumentation/runtime v0.71.0, contrib/instrumentation/net/http/otelhttp v0.71.0, golang.org/x/sync.
  2. Viết internal/config: HANGO_ENV, HANGO_HTTP_ADDR, HANGO_LOG_LEVEL, HANGO_SHUTDOWN_TIMEOUT, HANGO_OTEL_ENABLED, HANGO_OTEL_SAMPLE_RATIO. OTLP endpoint, protocol, service name và resource attribute được đọc từ các biến OTEL_* chuẩn mà các exporter của Go tự tuân theo; Load chỉ kiểm tra rằng chúng parse được.
  3. Viết internal/telemetry/telemetry.go: dựng một resource.Resource (service.name, service.version, deployment.environment.name), một sdktrace.TracerProvider với WithBatcher(otlptracehttp.New(...)) và một sampler ratio theo parent, một sdkmetric.MeterProvider với periodic reader trên otlpmetrichttp, một sdklog.LoggerProvider trên otlploghttp. Đặt các provider toàn cục và otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(tracecontext, baggage)). Dựng slog.Logger là một multi-handler: otelslog.NewHandler cho OTLP cộng một slog.JSONHandler/TextHandler cục bộ ra stdout (output cục bộ vẫn dễ đọc và còn nguyên khi collector sập); gọi slog.SetDefault. Trả về một Shutdown flush cả ba provider với timeout giới hạn.
  4. Viết internal/telemetry/noop.go: khi Enabled là false, trả về các provider no-op và một handler slog stdout thuần, để test và đường -version không bao giờ cần collector.
  5. Viết attrs.go, genai.go, langfuse.go với các hằng số ở trên và một comment ngắn cho mỗi nhóm nêu lý do hango khai báo chúng cục bộ. Viết metrics_names.go (chỉ các tên metric của Phase 1, để mọi giai đoạn sau mở rộng) và content_attrs.go với danh sách key content attribute và IsContentAttr.
  6. Gọi runtime.Start(runtime.WithMeterProvider(mp)) trong Init.
  7. Viết internal/server/health.go: các check có tên được bảo vệ bằng mutex với timeout riêng cho từng check, một cờ draining, và một map chi tiết dạng JSON.
  8. Viết internal/server/router.go và internal/server/middleware.go. Router bọc http.ServeMux của Go 1.22+:
    1. Handle(pattern, h) đăng ký một HandlerFunc cùng middleware của group. Sau khi routing, nó đổi tên span thành {method} {route pattern} từ r.Pattern và thêm http.route vào metric labeler của otelhttp, để metric HTTP mang route . Trước hết hãy kiểm tra xem otelhttp v0.71.0 đã đọc r.Pattern chưa, và bỏ phần nào nó đã làm sẵn.
    2. Error adapter biến một error được trả về thành một dạng JSON duy nhất ({"error": {"code", "message"}}): các typed error ánh xạ sang 400, 401, 403, 404 và 409, còn mọi lỗi khác là 500 với message không bao giờ là nội dung lỗi nội bộ.
    3. DecodeJSON giới hạn body bằng http.MaxBytesReader (mặc định 1 MiB), từ chối field không biết, và trả về lỗi 400.
    4. Một ResponseWriter ghi nhận status giữ lại status cho audit middleware (Phase 2) và cài Unwrap(), để http.ResponseController vẫn flush được SSE ở Phase 6.
    5. Middleware: tạo hoặc chấp nhận X-Request-Id; đặt X-Request-Id và X-Trace-Id (từ trace.SpanContextFromContext) lên response; một recover middleware ghi stack bằng slog.ErrorContext và ghi nhận panic lên span.
  9. Viết internal/server/server.go: dựng http.ServeMux và Router gốc, đăng ký các route health, rồi bọc mux bằng otelhttp.NewHandler(mux, "hango", otelhttp.WithFilter(skipHealthz)) và đưa kết quả cho http.Server. Đặt ReadHeaderTimeout và IdleTimeout; để WriteTimeout bằng 0 (SSE đến ở Phase 6 và một write timeout toàn cục sẽ âm thầm cắt cụt các stream). Start chạy server, và khi ctx.Done() thì đánh dấu draining, chờ 1 giây để load balancer thấy /readyz thất bại, rồi mới shutdown.
  10. Viết internal/app/app.go: config.Load rồi telemetry.Init rồi logger rồi server rồi errgroup; ghi một dòng log khởi động với version và tóm tắt config, không bao giờ ghi secret; defer telemetryShutdown sau khi server shutdown.
  11. Viết cmd/hango/main.go với signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM).
  12. Viết deploy/otelcol/config.yaml đúng như mục 3 của báo cáo observability: extension basicauth/langfuse từ LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY, receiver otlp trên 4317/4318, attributes/strip_content xóa đúng các key liệt kê trong internal/telemetry/content_attrs.go (không bao giờ dùng pattern) trên cả các pipeline lgtm và, qua attributes/strip_content_langfuse được chặn bởi hango.tenant.trace_llm_content=false, trên cả pipeline Langfuse, để tenant đã opt-out không bao giờ có nội dung rời khỏi collector ở bất kỳ nhánh nào , các exporter otlphttp/langfuse (${LANGFUSE_HOST}/api/public/otel, header x-langfuse-ingestion-version: "4") và otlp/lgtm (lgtm:4317, insecure), cùng bốn pipeline (traces/langfuse, traces/lgtm, metrics, logs).
  13. Viết compose.yaml với bốn service: hango (env OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318, OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf, OTEL_SERVICE_NAME=hango, OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=dev,service.version=${GIT_SHA:-dev}), postgres:17-alpine (dùng từ Phase 2), otelcol (otel/opentelemetry-collector-contrib:0.161.0, config được mount, port 4317/4318, env của Langfuse), lgtm (grafana/otel-lgtm:0.33.1, port 3000/4317/4318/3200/9090, volume lgtm-data:/data). Thêm deploy/langfuse/compose.langfuse.yaml sau profile langfuse-local để ghi tài liệu stack self-host 6 service (langfuse-web, langfuse-worker, clickhouse, redis, postgres, minio) kèm ghi chú ≥4 core / 16 GiB: chỉ ghi tài liệu, chưa nối dây trong giai đoạn này.
  14. Viết Makefile (build run test vet lint docker compose-up compose-down obs-up), .golangci.yml (errcheck, govet, staticcheck, ineffassign, unused, gosec, bodyclose, sqlclosecheck, revive), Dockerfile (multi-stage, distroless static nonroot, -trimpath -ldflags "-s -w -X main.version=$(VERSION)"), .env.example (gồm LANGFUSE_HOST=https://cloud.langfuse.com, key giữ chỗ).
  15. Viết test: test dạng bảng cho config; health pass/fail/draining; test server khẳng định các response 200 cùng sự hiện diện của X-Request-Id và X-Trace-Id; một test telemetry dùng in-memory span recorder khẳng định mỗi request có đúng một span SERVER mang tên route-pattern và /healthz bị lọc.
  16. Viết README.md, khung sườn docs/system-architecture.md với phần Observability được điền, docs/learning/README.md, và learning doc của giai đoạn.
  17. Chạy go build ./... && go vet ./... && go test ./... && golangci-lint run, sau đó docker compose up --build, curl các endpoint health, và xác nhận trong Grafana có trace của request, các dòng log của nó và metric Go runtime.

Dàn ý tài liệu học

docs/learning/01-foundation-and-observability-spine.md phải có:

  • Khái niệm: composition root, và vì sao observability là nền tảng — bạn không thể debug một harness mà bạn không nhìn thấy.
  • Các lựa chọn thiết kế: http.ServeMux của thư viện chuẩn với một router nhỏ tự viết thay vì framework, cấu hình chỉ qua env, liveness và readiness, signal.NotifyContext, một TracerProvider/MeterProvider/LoggerProvider, otelslog làm handler slog mặc định, một đường xuất OTLP tới collector, các hằng số attribute GenAI và Langfuse cục bộ của hango.
  • Vì sao dùng collector chứ không phải hai exporter trong tiến trình: Langfuse từ chối gRPC, việc che nội dung prompt phải cấu hình được mà không cần deploy lại, và hàng đợi của collector sống sót qua lúc Langfuse gặp sự cố.
  • Vì sao không dùng web framework: Echo, Gin, chi và Fiber đem lại gì (binding, xử lý lỗi tập trung, group, middleware dựng sẵn), từng thứ khớp vào khoảng 100 dòng ở đây ra sao, và vì sao fasthttp của Fiber sẽ làm hỏng SSE, otelhttp và cancellation. goclaw phục vụ toàn bộ API từ http.ServeMux theo cách tương tự (internal/http).
  • Đánh đổi: Grafana LGTM một container (chỉ dành cho dev/demo) so với stack tách rời collector/Tempo/Loki/Prometheus; Langfuse Cloud Hobby so với compose self-host 6 service; tỷ lệ sampling so với độ trung thực đầy đủ.
  • “Cách xem server đang làm gì”: một hướng dẫn từng bước — gửi một request, đọc X-Trace-Id từ response, mở Grafana Explore → Tempo → dán trace id → xem span → nhảy sang log Loki lọc theo trace_id → mở Prometheus xem metric runtime. Kèm đúng các URL và query của Grafana.
  • Cách debug tầng này: server không khởi động (danh sách lỗi config), server khởi động nhưng không thấy gì trong Grafana (log của collector, OTEL_EXPORTER_OTLP_ENDPOINT, HANGO_OTEL_ENABLED), trace có trong Tempo nhưng không có trong Langfuse (basic auth, header ingestion-version, hàng đợi exporter của collector).
  • So sánh với goclaw: thư mục cmd/ hơn 100 file, một /health duy nhất, config JSON5 với hot-reload bằng fsnotify, và tracing tự viết đệm span trong bộ nhớ rồi flush vào bảng traces/spans, với OTLP nằm sau một build tag.
  • So sánh với deepagents: không phải một server; phần lắp ráp là create_deep_agent ghép một danh sách middleware — cùng ý tưởng “một constructor sở hữu thứ tự” ở một tầng khác; tracing đến từ callback của LangSmith/LangChain chứ không phải OTel.
  • Vì sao hango đi khác: một file lắp ráp, hai endpoint health, không hot-reload, OTel chuẩn thay vì một kho span tự chế.
  • Vì sao tên metric và key content attribute là các danh sách do code sở hữu chứ không phải quy ước: dashboard và việc che nội dung đều hỏng một cách âm thầm khi một cái tên bị lệch.
  • Bài tập: thêm một giá trị config từ đầu đến cuối; làm /readyz thất bại và quan sát trình tự drain; đặt HANGO_OTEL_ENABLED=false và xác nhận binary vẫn chạy.
  • Ngân sách thời gian: 15h triển khai + 2h viết learning doc.

Tiêu chí thành công

  • go build ./... && go vet ./... && go test ./... đều pass.
  • golangci-lint run không báo phát hiện nào.
  • curl -i localhost:8080/readyz trả 200 với cả hai header X-Request-Id và X-Trace-Id được đặt.
  • docker compose up --build khởi động hango, postgres, otelcol, lgtm; tất cả healthy.
  • Trong Grafana (localhost:3000, admin/admin): trace của request tìm được trong Tempo theo giá trị X-Trace-Id; các dòng log của nó tìm được trong Loki theo trace_id; các series metric runtime go_ tồn tại trong Prometheus.
  • Cùng trace đó xuất hiện trong Langfuse khi LANGFUSE_PUBLIC_KEY/SECRET_KEY được đặt (kiểm tra thủ công, ảnh chụp hoặc ghi chú trong learning doc); khi LANGFUSE_PUBLIC_KEY không được đặt, collector không gửi gì tới Langfuse (quyết định ngày 2026-10-14, thay cho “ghi log lỗi export”) và pipeline lgtm không bị ảnh hưởng. Đã xác minh 2026-10-14: trace af0e142bed13f680656b70cc0873cc9c (GET /readyz) được Langfuse Cloud trả về qua GET /api/public/v2/observations.
  • Request /healthz không tạo span nào (khẳng định về bộ lọc trong test telemetry).
  • Các series http.server.request.duration mang nhãn http.route bằng với route pattern (test khẳng định labeler được áp dụng).
  • Lỗi của handler trở thành dạng JSON lỗi với status đúng, một 500 không bao giờ chứa nội dung lỗi nội bộ, body quá lớn hoặc có field không biết là 400, và writer ghi nhận status vẫn flush được qua http.ResponseController (test).
  • metrics_names.go tồn tại và mọi metric được đăng ký trong giai đoạn này đều đăng ký qua một trong các hằng số của nó (test khẳng định không có chuỗi tên metric dạng literal nằm ngoài file đó).
  • content_attrs.go liệt kê mọi key content attribute, và các câu lệnh strip trong config của collector đúng là những câu được sinh từ nó (test so sánh các câu lệnh được sinh với deploy/otelcol/config.yaml).
  • Gửi SIGTERM ghi một dòng drain, flush telemetry, và thoát với mã 0 trong HANGO_SHUTDOWN_TIMEOUT.
  • README.md, docs/system-architecture.md, docs/learning/README.md, docs/learning/01-foundation-and-observability-spine.md tồn tại và chính xác.

Bằng chứng, 2026-10-14: go build, go vet, go test ./... và golangci-lint run pass với 0 issue. docker compose up --build chạy cả bốn service healthy. /readyz trả 200 với X-Request-Id và X-Trace-Id. Trace 97ee9ef69ba48876bc66bdf601df2046 được tìm thấy trong Tempo (GET /readyz, span server) và dòng log request của nó trong Loki theo trace_id. Các series go_* và http_server_request_duration_seconds_* tồn tại trong Prometheus. docker compose stop hango ghi dòng drain và thoát với mã 0 sau khoảng 1 giây. Khi LANGFUSE_PUBLIC_KEY không được đặt, collector không gửi gì tới Langfuse. Với một key giả, nó ghi log lỗi 401 từ otlp_http/langfuse. Các nhánh lgtm vẫn chạy bình thường trong cả hai trường hợp. Langfuse Cloud và Grafana Cloud đều đã được xác minh trực tiếp vào 2026-10-14. Grafana Cloud: trace 5352c400856df46c952739b61d55a17f trong Tempo, log trong Loki, và 21 tên metric hango trong Prometheus.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuCách xử lý đã quyết định trước
Router tự viết phình thành một frameworkMedium x Lowrouter.go vượt khoảng 200 dòng hoặc có thêm tính năng không handler nào dùngGiữ ở mức Handle, Group, error adapter và DecodeJSON; chuyển sang chi, có handler là net/http thuần, nếu các route group trở nên khó đọc
sdk/log và otelslog chưa đạt 1.0High x MediumThay đổi phá vỡ khi nâng cấpGhim version; toàn bộ phần nối dây log nằm gọn trong internal/telemetry; handler stdout cục bộ đảm bảo log còn nguyên khi bridge hỏng
Container lgtm chỉ dành cho dev/demo và chưa đo được mức tiêu thụ tài nguyênHigh x LowDocker Desktop thiếu bộ nhớ trên macOSĐược ghi nhận là stack phát triển; docs/deployment-guide.md (Phase 16) chỉ định một backend được quản lý cho production; make obs-up có thể khởi động lgtm tách khỏi hango
Nội dung prompt rò vào Tempo/Loki hoặc LangfuseMedium x CriticalContent attribute hiện ra trên span trong Grafana hoặc LangfuseBa lớp: content attribute mặc định tắt trong app (HANGO_TRACE_LLM_CONTENT, Phase 3), tắt theo từng tenant trừ khi tenants.trace_llm_content là true (Phase 2), và bị collector loại bỏ theo danh sách key tường minh content_attrs.go trên các pipeline lgtm luôn luôn và trên pipeline Langfuse cho các tenant đã opt-out; Phase 3 kiểm thử cả cổng phía app lẫn phía tenant
GenAI semconv thay đổi liên tục làm hỏng tên attributeHigh x LowUpstream đổi tên attributeHằng số được khai báo cục bộ trong internal/telemetry/genai.go; đổi tên chỉ là sửa một file, không bao giờ là nâng cấp dependency
Hành vi ánh xạ của Langfuse có một phần chưa được xác minhHigh x MediumSpan đến nơi nhưng hiển thị thiếu input/output hoặc usageLuôn đặt các attribute langfuse.* một cách tường minh thay vì dựa vào suy luận từ convention chung; Phase 3 chạy một probe thực nghiệm để chốt ba câu hỏi còn mở
Wrapper của response writer che mất http.Flusher và làm hỏng SSEMedium x HighSự kiện SSE chỉ đến khi run kết thúcWriter ghi nhận status cài Unwrap(), mọi thao tác flush đi qua http.ResponseController, và một test của router flush qua wrapper
WriteTimeout toàn cục được thêm “cho an toàn” làm hỏng SSE ở Phase 6Low x HighStream bị cắt ở một khoảng thời gian cố định về sauWriteTimeout giữ bằng 0 kèm một comment trong server.go nêu lý do

Rollback

Phase 1 là greenfield; rollback là xóa nội dung repository. Trong phạm vi giai đoạn, HANGO_OTEL_ENABLED=false hạ xuống các provider no-op và logging ra stdout, nên một collector hoặc tài khoản Langfuse hỏng không bao giờ chặn việc phát triển.