Kiến trúc hệ thống
Tài liệu này mô tả hango như hiện tại đã được xây. Mỗi giai đoạn bổ sung hoặc cập nhật các phần của nó. Các phần ghi “Chưa xây” cho biết giai đoạn nào sẽ thêm chúng.
Tổng quan
hango là một binary Go duy nhất. cmd/hango/main.go xử lý flag và signal rồi gọi app.Run. internal/app là composition root: đây là nơi duy nhất tạo ra các dependency, và nó làm theo một thứ tự cố định.
config.Load -> telemetry.Init -> server.New -> pg.Open -> migrate -> bootstrap -> MountAPI -> errgroup(server.Start) -> wait
|
shutdown, in reverse: server drains and stops -> database pool closes -> telemetry flushes -> exit 0Các bước database chỉ chạy khi HANGO_DATABASE_URL được đặt. Nếu không, hango chỉ phục vụ các endpoint health và không gì khác. hango migrate chỉ chạy pg.Open và migrate rồi thoát.
Các package
| Package | Trách nhiệm | Phụ thuộc vào |
|---|---|---|
cmd/hango | Flag (-version, -healthcheck), subcommand migrate, signal.NotifyContext, exit code | internal/app |
internal/app | Kết nối các phần và vòng đời, khởi động database, bootstrap platform | tất cả package internal |
internal/config | Đọc và kiểm tra các biến môi trường | internal/telemetry (kiểu Config của nó), internal/auth (định dạng key) |
internal/auth | Sinh, hash và tra cứu API key; Principal của request | thư viện chuẩn |
internal/store | Các interface persistence, kiểu domain, Scope của tenant trên context | thư viện chuẩn |
internal/store/pg | Cài đặt Postgres của internal/store; package duy nhất import pgx. Các query là file SQL trong queries/, và sqlc sinh code chạy chúng vào package này | internal/store, migrations, pgx, goose, otelpgx |
migrations | Các file SQL goose được nhúng | thư viện chuẩn |
internal/server | Router, middleware, helper JSON, endpoint health, vòng đời http.Server, authentication, authorization, audit, admin API | internal/config, internal/telemetry, internal/auth, internal/store |
internal/telemetry | Các provider OpenTelemetry, logger slog mặc định, attribute key, tên metric, danh sách attribute chứa nội dung | OpenTelemetry SDK |
Mô hình dữ liệu
Postgres 17, truy cập qua pgx v5. Migration là các file SQL trong migrations/, được nhúng vào binary và goose áp dụng lúc khởi động (HANGO_MIGRATE_ON_START) hoặc bằng hango migrate. hango đưa cho goose một Postgres session locker, nên các replica khởi động cùng lúc, hoặc hango migrate chạy đua với một pod đang khởi động, vẫn chỉ áp dụng mỗi file một lần. Id là UUIDv7. Các query nằm trong internal/store/pg/queries/*.sql. make generate chạy sqlc, đọc các file đó và các migration rồi ghi models.go, db.go và một file *.sql.go cho mỗi file query vào internal/store/pg. Các file viết tay ở đó chỉ dựng tham số từ tenant scope, gọi method được sinh ra, và ánh xạ kết quả sang các kiểu của internal/store. make sqlc-check fail khi code sinh ra đã commit bị cũ.
| Bảng | Thuộc tenant | Nội dung |
|---|---|---|
tenants | không | slug, name, kind (platform hoặc customer, tối đa một platform), status, trace_llm_content |
users | có | email (duy nhất trong mỗi tenant), role (owner, admin, member), status |
api_keys | có | user_id, name, lookup (duy nhất), key_hash, last_used_at, revoked_at, expires_at |
audit_events | có | actor, key, action, target, request_id, trace_id, span_id, outcome (ok, denied, error), detail |
Mọi bảng thuộc tenant đều có tenant_id NOT NULL và một index bắt đầu bằng nó. audit_events chỉ được thêm vào (append-only): store không có UPDATE hay DELETE cho nó, và API không có route nào thay đổi nó. Các test kiểm tra cả hai điều này.
Luồng của request
Mọi request đều đi qua cùng các middleware, từ ngoài cùng vào trong:
| Lớp | Việc làm |
|---|---|
otelhttp | Bắt đầu server span, đặt tên là {method} {route}, ghi http.server.request.duration kèm http.route. Bỏ qua /healthz. Một traceparent đến từ ngoài chỉ được giữ làm link. |
recordStatus | Bọc response writer để code phía sau đọc được status. Giữ http.ResponseController hoạt động thông qua Unwrap. |
requestID | Nhận X-Request-Id an toàn từ client hoặc tạo một UUIDv7. Gắn nó vào span, vào log context và vào response, cùng với X-Trace-Id. |
logRequests | Ghi một dòng log request với method, route, status và thời lượng, kèm id của tenant và user khi request đã xác thực. Bỏ qua /healthz. |
recoverPanics | Biến panic của handler thành một lỗi 500 có ghi log và trace. |
http.ServeMux | Khớp route pattern và gọi handler. |
authenticator | Chỉ các route API. Pre-auth limiter, rồi phân giải key. Xem Tenancy. |
auditRecorder | Chỉ các route API. Ghi một dòng audit_events cho mỗi POST, PUT, PATCH hoặc DELETE sau khi handler trả về. |
Handler trả về một error. Router hiển thị các giá trị *server.Error với status và message của chúng, và biến mọi lỗi khác thành 500 mà body không bao giờ chứa nội dung lỗi.
| Endpoint | Ý nghĩa |
|---|---|
GET /healthz | Liveness: tiến trình đang chạy. Luôn trả 200. Không trace, không ghi log. |
GET /readyz | Readiness: mọi check đã đăng ký đều đạt và server không đang drain. Trả 200 hoặc 503. Check lỗi hiện là "fail"; nội dung lỗi đi vào log. Khi có database, check database ping nó trong span riêng. |
POST /v1/admin/tenants | Chỉ platform. Tạo một customer tenant. |
POST /v1/admin/tenants/{id}/users | Platform (chỉ owner đầu tiên), owner, admin (không phải owner). |
POST /v1/users/{id}/api-keys | Platform (chỉ cho một owner, khi tenant chưa có owner key dùng được), owner, admin (chính nó hoặc một member), member (chính nó). Key plaintext chỉ có trong response này. |
DELETE /v1/api-keys/{id} | Owner và admin, hoặc member với key của chính nó. Revoke. Platform chỉ revoke được key trong tenant của chính nó. |
GET /v1/me | User và tenant của người gọi. |
GET /v1/audit-events | Owner và admin thấy cả tenant, member thấy các dòng của chính nó. Platform thấy audit trail của tenant của chính nó. Bộ lọc: since, action, target_id, limit. |
Vòng đời của run
Chưa xây. Giai đoạn 5 thêm các run và state machine của chúng.
Tenancy
Việc phân quyền có hai trục, đặt ở hai nơi:
- Một request chạm được tenant nào do store quyết định.
store.WithScopeđặt mộtScope{TenantID, UserID, Role}lên context, vàStore.For(ctx)biến nó thành một handleScopedmà mọi query đều mangtenant_id. Không method nào củaScopednhận tham số tenant. Thiếu scope, tenant nil hoặc role không hợp lệ đều trảErrNoTenant.ListvàRevokecủa một member bị thu hẹp vào các dòng của chính nó;Getvẫn trong phạm vi cả tenant để authorization có thể trả 403. - Người gọi được làm gì trong tenant của mình do một bảng duy nhất trong
internal/server/authz.goquyết định, theo từng route. Một target ở tenant khác được báo là 404, không bao giờ là 403.
API key có dạng hango_<slug>_<43 hoặc 44 ký tự base58>. 8 ký tự đầu của phần thân là lookup được đánh index, và store chỉ giữ SHA-256 của toàn bộ key, so sánh trong thời gian hằng số. Plaintext chỉ hiện một lần, trong response tạo key.
Middleware authenticator chạy theo thứ tự: pre-auth limiter (một token bucket cho mỗi IP client mà chỉ các lần thất bại mới tiêu token, nên một đợt dồn key sai nhận 429 trước khi có bất kỳ query nào, dù chúng dùng key nào; một key đã xác thực trong mười phút gần nhất đi qua được bucket rỗng, và limiter giữ tối đa 65.536 bucket), tra cứu key, so sánh hash, rồi các kiểm tra revoked, expired và inactive. Mọi thất bại đều là cùng một 401 với client; lý do đi vào hango.auth.failures và log. Khi thành công nó đặt Principal và scope lên context, id của tenant và user lên span, log và các span con, và ghi last_used_at ở nền nhiều nhất một lần cho mỗi key trong mỗi HANGO_KEY_LAST_USED_INTERVAL.
Platform operator là user owner của một tenant platform duy nhất. Ở lần khởi động đầu tiên với database trống, hango tạo tenant đó, user [email protected] và một dòng key cho HANGO_BOOTSTRAP_ADMIN_KEY, trong một transaction. Sau đó biến này bị bỏ qua chừng nào platform còn một key dùng được, nên việc revoke hoặc rotate key được giữ nguyên. Nếu platform không còn key dùng được, một HANGO_BOOTSTRAP_ADMIN_KEY mới cùng một lần khởi động lại sẽ lưu key đó cho owner của platform. Một key đã bị revoke không bao giờ được hồi sinh. Trong tenant của chính nó, user platform được coi là owner của tenant. Khi platform tác động lên một customer tenant, nó dùng scope cấp owner bên trong tenant đó, dòng audit nằm ở tenant đó, và span cùng log mang hango.acting_tenant.id. Khóa dòng của tenant dùng để tuần tự hóa các kiểm tra “user đầu tiên” và “chưa có owner key dùng được” của platform là for no key update.
Observability
hango gửi cả ba tín hiệu qua OTLP/HTTP tới một OpenTelemetry Collector, và collector chia dữ liệu đi tiếp.
hango --OTLP/HTTP--> otelcol --+-- traces --> Langfuse (only when LANGFUSE_PUBLIC_KEY is set)
+-- traces --> lgtm: Tempo (content attributes stripped)
+-- metrics --> lgtm: Prometheus
+-- logs --> lgtm: Loki (content attributes stripped)
+-- all three --> Grafana Cloud (only when GRAFANA_CLOUD_INSTANCE_ID is set, content stripped)- Trace: một
TracerProvidervới batch exporter và sampler theo tỉ lệ dựa trên parent (HANGO_OTEL_SAMPLE_RATIO).OTEL_TRACES_SAMPLERbị từ chối khi khởi động thay vì lặng lẽ bỏ qua;OTEL_SDK_DISABLED=truetắt telemetry giống nhưHANGO_OTEL_ENABLED=false. - Metric: một
MeterProvidervới periodic reader. Các metric runtime của Go (go_memory_used_bytes,go_goroutine_count, và nhiều cái khác) bắt đầu từ lúc init. Mọi tên metric mà code đăng ký đều là hằng số tronginternal/telemetry/metrics_names.go, và một test sẽ fail nếu có tên viết thẳng ở chỗ khác. - Log: logger
slogmặc định ghi ra stdout (text ở dev, JSON ở prod) và ra bridgeotelslog. Cả hai đầu ra đều mang trace id và span id, nên một dòng log tìm thấy trên stdout hoặc trong Loki dẫn được tới trace của nó. - Attribute key: các key riêng của hango (
hango.*) nằm trongattrs.go. Các key GenAI semantic convention được chép vàogenai.govà key của Langfuse vàolangfuse.go, để việc upstream đổi tên không thể làm chúng thay đổi trong im lặng. - Che nội dung (content redaction):
content_attrs.goliệt kê mọi attribute có thể chứa nội dung prompt, completion hoặc tool, và danh sách này được áp dụng hai lần. Logger bỏ các key đó khỏi mọi log record trước khi nó tới stdout hoặc collector, ở mọi độ sâu bên trong group. Sau đó các processortransformcủa collector xóa đúng những key đó khỏi attribute của span, attribute của span event, attribute của log record và body log dạng map, trên nhánh lgtm và Grafana Cloud luôn luôn; đây là lớp bảo vệ phụ cho span và cho mọi thứ đi vòng qua logger. Nhánh Langfuse là fail-closed: nó lọc bỏ trừ khi span manghango.tenant.trace_llm_contentvới giá trị booleantrue(span event đi theo span của chúng), nên mọi span có thể mang nội dung cũng phải mang cờ này. Body log dạng chuỗi không được kiểm tra, nên đừng bao giờ ghi nội dung vào phần message. Span link và attribute của resource và scope không bị lọc; hango không bao giờ đặt nội dung ở đó. Các câu lệnh của collector được sinh từ danh sách Go, và một test giữ cho chúng giống hệt danh sách đó. - Database: otelpgx tạo một CLIENT span cho mỗi query và mỗi lần pool acquire, kèm câu lệnh SQL và không có tham số, nên một request chậm hiện các query của nó như span con. sqlc bắt đầu mỗi câu lệnh bằng
-- name: <Query>, nên attributedb.query.textnêu tên query. Một span processor (telemetry.ContextSpanAttrs) chép id của tenant và user mà auth middleware đã đặt lên context vào mọi span bắt đầu từ nó, kể cả span của pgx. Việc tra cứu key chạy trước khi biết tenant, nên span của nó không có tenant. Các metric của giai đoạn 2:hango.auth.failures(theoreason),hango.auth.prelimit.rejections,hango.db.migrations.applied,hango.audit.eventsvàhango.audit.write_failures. - Resource:
service.name,service.versionvàdeployment.environment.name, có thể ghi đè bằngOTEL_SERVICE_NAMEvàOTEL_RESOURCE_ATTRIBUTES. - Shutdown:
HANGO_SHUTDOWN_TIMEOUTlà một ngân sách thời gian duy nhất bắt đầu tính khi signal đến. Server drain trong ngân sách đó và chừa tối đa 2 giây cho telemetry, telemetry flush sau khi server dừng để span và log của các request cuối cùng được xuất đi. Tiến trình thoát trong ngân sách.
Container lgtm (grafana/otel-lgtm) gộp Grafana, Tempo, Loki và Prometheus, chỉ dùng cho development. Langfuse mặc định dùng Langfuse Cloud; deploy/langfuse/compose.langfuse.yaml ghi lại stack tự host. Cách thiết lập cả hai cloud được nói trong observability-cloud-setup.md.
Deploy
Development chạy bằng compose.yaml: hango, postgres, otelcol, lgtm. hango nhận HANGO_DATABASE_URL cho service postgres và HANGO_BOOTSTRAP_ADMIN_KEY từ .env. Image là một binary tĩnh trên gcr.io/distroless/static-debian12:nonroot, và health check của compose chạy /hango -healthcheck vì image không có shell. Một Helm chart cho Kubernetes được mô tả trong kubernetes-deployment.md. Phần còn lại của deploy production là giai đoạn 16.