From 51b7ba13e14926cb249a57f2d11777431599dc1e Mon Sep 17 00:00:00 2001 From: Ryan McGuire Date: Sat, 4 Jul 2026 17:19:09 -0400 Subject: [PATCH] add AGENTS.md --- AGENTS.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e53f9d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# 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+) + +```go +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 +``` + +- `MustLoadConfigInto` loads the `-config` YAML + env into a struct that embeds + `*config.AppConfig`; `MustSetupConfigAndLogging` is the simple (AppConfig-only) + variant. Config loading owns `flag.Parse`, so register app flags in `init()`. +- `MustRun` starts 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 + from `GRPCConfig`. +- **HTTP** (`pkg/srv/http/opts.AppHTTP`): `Funcs` (path + handler), `Handlers` + (`HTTPHandler{Prefix|Pattern, StripPrefix, Handler}` for muxes), and + `HealthChecks`. `/health` and 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) + +1. **Custom-field env is NOT applied.** `MustLoadConfigInto` only parses env for + the embedded `AppConfig`; its own doc says custom env is "up to the caller." + So `env:"..."` tags on custom fields do nothing unless the app runs + `env.Parse` itself. `MustSetupConfigAndLoggingInto[T]` looks like the intended + helper but is currently an **empty stub** (returns its args unchanged). + → Good candidate: make `MustLoadConfigInto`/that stub run `env.Parse` over the + custom struct (skipping the embedded `AppConfig` to preserve precedence). +2. **grpc-gateway path strip defaults to false.** `GRPCGatewayPath` defaults to + `/grpc-api` but `GRPCGatewayPathStrip` defaults `false`, so the gateway sees + the full prefixed path and 404s every route. Apps must set + `grpcGatewayPathStrip: true` (or accept requests must include the prefix in + the proto `google.api.http` path). Consider defaulting strip to true. +3. **Prometheus unit suffixing.** The OTEL→Prometheus exporter appends the OTEL + unit as a name suffix, so `metric.WithUnit("°F")` on `econet_setpoint_fahrenheit` + yields `..._fahrenheit_F`. Either don't set WithUnit (bake units into names) + or enable `prometheus.WithoutUnits()` in the meter setup. +4. README's example `main.go` is stale (references removed `app.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).