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/hangobuild ra một binary khởi động HTTP server tại địa chỉ đã cấu hình.GET /healthzluôn trả 200;GET /readyzchạy các check đã đăng ký và báodrainingkhi 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/SIGTERMkí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-Idvà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 upkhởi độnghango,postgres,otelcolvàlgtm; Grafana tạilocalhost:3000hiể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/slogvới OTel bridge. - Một đường xuất OTLP duy nhất (
http/protobuftớ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 runsạ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.NewHandlerbọ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êmhango.tenant.idtừ Phase 2 trở đi. - Metric:
contrib/instrumentation/runtime(runtime.Start) cho các series của Go runtime;http.server.request.durationtừotelhttp.otelhttpbọc mux và chạy trước khi routing, nên nó không thấy route. Router thêmhttp.route(phần path củar.Pattern) vào metric labeler quaotelhttp.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 handlerslogmặc định, nên các recordslog.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ộtotelhttp.WithFilter. - Collector fan-out: trace đi tới Langfuse (
otlphttpvớ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 tronginternal/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 manghango.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— modulegithub.com/phihdn/hango, Go 1.27.cmd/hango/main.go— flag (-version),signal.NotifyContext, gọiapp.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
go mod init github.com/phihdn/hango;go 1.27,toolchain go1.27.1. Thêmgo.opentelemetry.io/otel+/sdk+/sdk/metricv1.46.0,go.opentelemetry.io/otel/sdk/logv0.22.0,otlptracehttp/otlpmetrichttp/otlploghttp,contrib/bridges/otelslogv0.20.1,contrib/instrumentation/runtimev0.71.0,contrib/instrumentation/net/http/otelhttpv0.71.0,golang.org/x/sync.- 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ếnOTEL_*chuẩn mà các exporter của Go tự tuân theo;Loadchỉ kiểm tra rằng chúng parse được. - Viết
internal/telemetry/telemetry.go: dựng mộtresource.Resource(service.name,service.version,deployment.environment.name), mộtsdktrace.TracerProvidervớiWithBatcher(otlptracehttp.New(...))và một sampler ratio theo parent, mộtsdkmetric.MeterProvidervới periodic reader trênotlpmetrichttp, mộtsdklog.LoggerProvidertrênotlploghttp. Đặt các provider toàn cục vàotel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(tracecontext, baggage)). Dựngslog.Loggerlà một multi-handler:otelslog.NewHandlercho OTLP cộng mộtslog.JSONHandler/TextHandlercục bộ ra stdout (output cục bộ vẫn dễ đọc và còn nguyên khi collector sập); gọislog.SetDefault. Trả về mộtShutdownflush cả ba provider với timeout giới hạn. - Viết
internal/telemetry/noop.go: khiEnabledlà false, trả về các provider no-op và một handlerslogstdout thuần, để test và đường-versionkhông bao giờ cần collector. - Viết
attrs.go,genai.go,langfuse.govớ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ếtmetrics_names.go(chỉ các tên metric của Phase 1, để mọi giai đoạn sau mở rộng) vàcontent_attrs.govới danh sách key content attribute vàIsContentAttr. - Gọi
runtime.Start(runtime.WithMeterProvider(mp))trongInit. - 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. - Viết
internal/server/router.govàinternal/server/middleware.go. Router bọchttp.ServeMuxcủa Go 1.22+:Handle(pattern, h)đăng ký mộtHandlerFunccùng middleware của group. Sau khi routing, nó đổi tên span thành{method} {route pattern}từr.Patternvà thêmhttp.routevào metric labeler củaotelhttp, để metric HTTP mang route . Trước hết hãy kiểm tra xemotelhttpv0.71.0 đã đọcr.Patternchưa, và bỏ phần nào nó đã làm sẵn.- 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ộ. DecodeJSONgiới hạn body bằnghttp.MaxBytesReader(mặc định 1 MiB), từ chối field không biết, và trả về lỗi 400.- Một
ResponseWriterghi nhận status giữ lại status cho audit middleware (Phase 2) và càiUnwrap(), đểhttp.ResponseControllervẫn flush được SSE ở Phase 6. - Middleware: tạo hoặc chấp nhận
X-Request-Id; đặtX-Request-IdvàX-Trace-Id(từtrace.SpanContextFromContext) lên response; một recover middleware ghi stack bằngslog.ErrorContextvà ghi nhận panic lên span.
- Viết
internal/server/server.go: dựnghttp.ServeMuxvàRoutergốc, đăng ký các route health, rồi bọc mux bằngotelhttp.NewHandler(mux, "hango", otelhttp.WithFilter(skipHealthz))và đưa kết quả chohttp.Server. ĐặtReadHeaderTimeoutvàIdleTimeout; đểWriteTimeoutbằ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).Startchạy server, và khictx.Done()thì đánh dấu draining, chờ 1 giây để load balancer thấy/readyzthất bại, rồi mới shutdown. - Viết
internal/app/app.go:config.Loadrồitelemetry.Initrồi logger rồi server rồierrgroup; ghi một dòng log khởi động với version và tóm tắt config, không bao giờ ghi secret;defertelemetryShutdownsau khi server shutdown. - Viết
cmd/hango/main.govớisignal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM). - Viết
deploy/otelcol/config.yamlđúng như mục 3 của báo cáo observability: extensionbasicauth/langfusetừLANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY, receiverotlptrên 4317/4318,attributes/strip_contentxóa đúng các key liệt kê tronginternal/telemetry/content_attrs.go(không bao giờ dùng pattern) trên cả các pipeline lgtm và, quaattributes/strip_content_langfuseđược chặn bởihango.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 exporterotlphttp/langfuse(${LANGFUSE_HOST}/api/public/otel, headerx-langfuse-ingestion-version: "4") vàotlp/lgtm(lgtm:4317, insecure), cùng bốn pipeline (traces/langfuse,traces/lgtm,metrics,logs). - Viết
compose.yamlvới bốn service:hango(envOTEL_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, volumelgtm-data:/data). Thêmdeploy/langfuse/compose.langfuse.yamlsau profilelangfuse-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. - 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ồmLANGFUSE_HOST=https://cloud.langfuse.com, key giữ chỗ). - 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-Idvà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à/healthzbị lọc. - Viết
README.md, khung sườndocs/system-architecture.mdvới phần Observability được điền,docs/learning/README.md, và learning doc của giai đoạn. - 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.ServeMuxcủ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,otelsloglàm handlerslogmặ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,
otelhttpvà cancellation. goclaw phục vụ toàn bộ API từhttp.ServeMuxtheo 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-Idtừ response, mở Grafana Explore → Tempo → dán trace id → xem span → nhảy sang log Loki lọc theotrace_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/healthduy 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ảngtraces/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_agentghé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
/readyzthất bại và quan sát trình tự drain; đặtHANGO_OTEL_ENABLED=falsevà 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 runkhông báo phát hiện nào. -
curl -i localhost:8080/readyztrả 200 với cả hai headerX-Request-IdvàX-Trace-Idđược đặt. -
docker compose up --buildkhởi độnghango,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 theotrace_id; các series metric runtimego_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); khiLANGFUSE_PUBLIC_KEYkhô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: traceaf0e142bed13f680656b70cc0873cc9c(GET /readyz) được Langfuse Cloud trả về quaGET /api/public/v2/observations. - Request
/healthzkhông tạo span nào (khẳng định về bộ lọc trong test telemetry). - Các series
http.server.request.durationmang nhãnhttp.routebằ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.gotồ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.goliệ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ớideploy/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.mdtồ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 ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
| Router tự viết phình thành một framework | Medium x Low | router.go vượt khoảng 200 dòng hoặc có thêm tính năng không handler nào dùng | Giữ ở 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.0 | High x Medium | Thay đổi phá vỡ khi nâng cấp | Ghim 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ên | High x Low | Docker 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 Langfuse | Medium x Critical | Content attribute hiện ra trên span trong Grafana hoặc Langfuse | Ba 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 attribute | High x Low | Upstream đổi tên attribute | Hằ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 minh | High x Medium | Span đến nơi nhưng hiển thị thiếu input/output hoặc usage | Luô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 SSE | Medium x High | Sự kiện SSE chỉ đến khi run kết thúc | Writer 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 6 | Low x High | Stream bị cắt ở một khoảng thời gian cố định về sau | WriteTimeout 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.