Compare commits

..

4 Commits

Author SHA1 Message Date
rmcguire 51b7ba13e1 add AGENTS.md 2026-07-04 17:19:09 -04:00
rmcguire e796daa50c update CHANGELOG 2026-01-23 22:38:34 -05:00
rmcguire d246ba7c16 stop hijacking / 2026-01-23 22:12:44 -05:00
rmcguire 04a626df60 update CHANGELOG 2026-01-23 21:00:48 -05:00
3 changed files with 110 additions and 1 deletions
+89
View File
@@ -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).
+21
View File
@@ -1,3 +1,24 @@
# v0.17.1
* stop hijacking /
# v0.17.0
* feat: Add CORS support
# v0.16.1
* fix custom type management
# v0.16.0
* enhance context management for custom types
# v0.15.0
* updates go dependencies, refactors config loading precedence, adds gRPC gateway path stripping option, and improves HTTP handler setup.
# v0.14.0
* Improve serve mux path
# v0.13.0
* add default tag name
# v0.12.1 # v0.12.1
* feat: Implement custom OpenTelemetry span name formatting for HTTP requests. * feat: Implement custom OpenTelemetry span name formatting for HTTP requests.
* refactor: Streamline OpenTelemetry handler integration by removing custom wrapper. * refactor: Streamline OpenTelemetry handler integration by removing custom wrapper.
-1
View File
@@ -44,7 +44,6 @@ func prepHTTPServer(opts *opts.AppHTTP) *http.Server {
healthChecks := handleHealthCheckFunc(opts.Ctx, opts.HealthChecks...) healthChecks := handleHealthCheckFunc(opts.Ctx, opts.HealthChecks...)
mux.HandleFunc("/health", healthChecks) mux.HandleFunc("/health", healthChecks)
mux.HandleFunc("/", healthChecks)
for _, f := range opts.Funcs { for _, f := range opts.Funcs {
mux.HandleFunc(f.Path, f.HandlerFunc) mux.HandleFunc(f.Path, f.HandlerFunc)