4.3 KiB
AGENTS.md — go-app
A small framework for bootstrapping logging, OTEL, and HTTP/gRPC servers from
config + environment. Apps embed *config.AppConfig in a custom config struct
and register services; the framework runs the servers and lifecycle.
The app init pattern (current, v0.17+)
ctx, cncl := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT)
defer cncl()
// Custom config that embeds *config.AppConfig:
ctx, cfg := app.MustLoadConfigInto(ctx, &config.ServiceConfig{})
cfg.LoadEnv() // SEE GOTCHA 1 — custom-field env is the caller's job
a := &app.App{AppContext: ctx}
a.InitOTEL()
for _, svc := range appServices { sdFunc, _ := svc.Init(ctx, cfg); ... }
a.GRPC, a.HTTP = service.MergeServices(ctx, appServices...) // app-side helper
a.MustRun()
<-a.Done()
// then run collected ShutdownFuncs within a grace period
MustLoadConfigIntoloads the-configYAML + env into a struct that embeds*config.AppConfig;MustSetupConfigAndLoggingis the simple (AppConfig-only) variant. Config loading ownsflag.Parse, so register app flags ininit().MustRunstarts gRPC then HTTP then a monitor goroutine; panics if neither is enabled.Done()unblocks on ctx cancel or a server exit.
Registering servers
Fill option structs and assign to App.GRPC / App.HTTP (or use a
service.AppService + MergeServices convenience layer per app):
- gRPC (
pkg/srv/grpc/opts.AppGRPC): append*GRPCService{Name, Type (*grpc.ServiceDesc), Service (impl), GwRegistrationFuncs}. At least one service must add a transport-credentials dial option (e.g.insecure.NewCredentials()) so the grpc-gateway can dial the server. Instrumentation, protovalidate, reflection, and the gateway are auto-wired fromGRPCConfig. - HTTP (
pkg/srv/http/opts.AppHTTP):Funcs(path + handler),Handlers(HTTPHandler{Prefix|Pattern, StripPrefix, Handler}for muxes), andHealthChecks./healthand the Prometheus endpoint are mounted for you. - MCP is not a framework feature: mount an MCP streamable-HTTP handler as an
HTTPHandler{Prefix: "/api/mcp", Handler: ...}.
OTEL / Prometheus
InitOTEL() sets up tracer + meter providers (no-op if disabled). The meter
provider always includes a Prometheus reader; the HTTP server mounts
promhttp at OTEL.PrometheusPath (default /metrics). OTLP export
auto-enables when OTEL_EXPORTER_OTLP_ENDPOINT is set. Helpers:
otel.GetTracer(ctx, name), otel.GetMeter(ctx, name).
Config & schema
AppConfig covers APP_* env (name/env/version, http, grpc, otel, logging).
Custom fields are validated for schema via app.CustomSchema(&cfg). Precedence
for AppConfig: defaults → env → YAML file.
Known gotchas (fix candidates)
- Custom-field env is NOT applied.
MustLoadConfigIntoonly parses env for the embeddedAppConfig; its own doc says custom env is "up to the caller." Soenv:"..."tags on custom fields do nothing unless the app runsenv.Parseitself.MustSetupConfigAndLoggingInto[T]looks like the intended helper but is currently an empty stub (returns its args unchanged). → Good candidate: makeMustLoadConfigInto/that stub runenv.Parseover the custom struct (skipping the embeddedAppConfigto preserve precedence). - grpc-gateway path strip defaults to false.
GRPCGatewayPathdefaults to/grpc-apibutGRPCGatewayPathStripdefaultsfalse, so the gateway sees the full prefixed path and 404s every route. Apps must setgrpcGatewayPathStrip: true(or accept requests must include the prefix in the protogoogle.api.httppath). Consider defaulting strip to true. - Prometheus unit suffixing. The OTEL→Prometheus exporter appends the OTEL
unit as a name suffix, so
metric.WithUnit("°F")oneconet_setpoint_fahrenheityields..._fahrenheit_F. Either don't set WithUnit (bake units into names) or enableprometheus.WithoutUnits()in the meter setup. - README's example
main.gois stale (references removedapp.AppHTTP/srv.HTTPFunc); trust the template app instead.
Layout
pkg/app (bootstrap/lifecycle/schema) · pkg/config (AppConfig, env/yaml) ·
pkg/logging (zerolog) · pkg/otel (providers + helpers) · pkg/srv/grpc
(+gateway) · pkg/srv/http (+health, +prometheus).