Chuyển đến nội dung
Kiến trúc hệ thống

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 0

Cá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

PackageTrách nhiệmPhụ thuộc vào
cmd/hangoFlag (-version, -healthcheck), subcommand migrate, signal.NotifyContext, exit codeinternal/app
internal/appKết nối các phần và vòng đời, khởi động database, bootstrap platformtất cả package internal
internal/configĐọc và kiểm tra các biến môi trườnginternal/telemetry (kiểu Config của nó), internal/auth (định dạng key)
internal/authSinh, hash và tra cứu API key; Principal của requestthư viện chuẩn
internal/storeCác interface persistence, kiểu domain, Scope của tenant trên contextthư viện chuẩn
internal/store/pgCà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àyinternal/store, migrations, pgx, goose, otelpgx
migrationsCác file SQL goose được nhúngthư viện chuẩn
internal/serverRouter, middleware, helper JSON, endpoint health, vòng đời http.Server, authentication, authorization, audit, admin APIinternal/config, internal/telemetry, internal/auth, internal/store
internal/telemetryCác provider OpenTelemetry, logger slog mặc định, attribute key, tên metric, danh sách attribute chứa nội dungOpenTelemetry 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ảngThuộc tenantNội dung
tenantskhôngslug, name, kind (platform hoặc customer, tối đa một platform), status, trace_llm_content
userscóemail (duy nhất trong mỗi tenant), role (owner, admin, member), status
api_keyscóuser_id, name, lookup (duy nhất), key_hash, last_used_at, revoked_at, expires_at
audit_eventscó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ớpViệc làm
otelhttpBắ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.
recordStatusBọc response writer để code phía sau đọc được status. Giữ http.ResponseController hoạt động thông qua Unwrap.
requestIDNhậ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.
logRequestsGhi 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.
recoverPanicsBiến panic của handler thành một lỗi 500 có ghi log và trace.
http.ServeMuxKhớp route pattern và gọi handler.
authenticatorChỉ các route API. Pre-auth limiter, rồi phân giải key. Xem Tenancy.
auditRecorderChỉ 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 /healthzLiveness: tiến trình đang chạy. Luôn trả 200. Không trace, không ghi log.
GET /readyzReadiness: 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/tenantsChỉ platform. Tạo một customer tenant.
POST /v1/admin/tenants/{id}/usersPlatform (chỉ owner đầu tiên), owner, admin (không phải owner).
POST /v1/users/{id}/api-keysPlatform (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/meUser và tenant của người gọi.
GET /v1/audit-eventsOwner 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ột Scope{TenantID, UserID, Role} lên context, và Store.For(ctx) biến nó thành một handle Scoped mà mọi query đều mang tenant_id. Không method nào của Scoped nhận tham số tenant. Thiếu scope, tenant nil hoặc role không hợp lệ đều trả ErrNoTenant. List và Revoke của một member bị thu hẹp vào các dòng của chính nó; Get vẫ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.go quyế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 TracerProvider với batch exporter và sampler theo tỉ lệ dựa trên parent (HANGO_OTEL_SAMPLE_RATIO). OTEL_TRACES_SAMPLER bị từ chối khi khởi động thay vì lặng lẽ bỏ qua; OTEL_SDK_DISABLED=true tắt telemetry giống như HANGO_OTEL_ENABLED=false.
  • Metric: một MeterProvider vớ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ố trong internal/telemetry/metrics_names.go, và một test sẽ fail nếu có tên viết thẳng ở chỗ khác.
  • Log: logger slog mặc định ghi ra stdout (text ở dev, JSON ở prod) và ra bridge otelslog. 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 trong attrs.go. Các key GenAI semantic convention được chép vào genai.go và key của Langfuse vào langfuse.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.go liệ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 processor transform củ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 mang hango.tenant.trace_llm_content với giá trị boolean true (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 attribute db.query.text nê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 (theo reason), hango.auth.prelimit.rejections, hango.db.migrations.applied, hango.audit.events và hango.audit.write_failures.
  • Resource: service.name, service.version và deployment.environment.name, có thể ghi đè bằng OTEL_SERVICE_NAME và OTEL_RESOURCE_ATTRIBUTES.
  • Shutdown: HANGO_SHUTDOWN_TIMEOUT là 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.