Skip to content

feat(cli): serve Prometheus metrics from the CLI on a separate admin port (#94) - #106

Draft
harrydayexe wants to merge 28 commits into
mainfrom
rain/issue-94
Draft

harrydayexe wants to merge 28 commits into
mainfrom
rain/issue-94

Conversation

@harrydayexe

@harrydayexe harrydayexe commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #94

Note

This pull request was written by rain running Claude Code. It has not been reviewed by a human. CI and an automated review pass run before it is handed over.

Issue

#94: feat(cli): serve Prometheus metrics from the CLI on a separate admin port

What the agent reports

Pushed. Here's the handover.

What I found first

Issue #94 says it depends on #92 and #93 and must not start before both land. Neither is on main, which is where my branch was cut from:

Without #93 the exporter would land in the root go.mod, which the issue explicitly calls "the exact outcome #93 exists to prevent" and which its acceptance criteria forbid. So rather than re-do or work around either, I merged both into this branch and built on top:

  • f279ae2 merge origin/v3-changes (brings the cli/ module).
  • 18ad366 merge origin/rain/issue-92, porting WithMeterProvider onto v3's variadic ServerOption API (MeterProvider joins config.ServerConfig; the option becomes a ServerOption).

This PR should target v3-changes, not main. Against main the diff includes all of v3; against v3-changes it's just the #92 commits plus my five.

What I implemented (#94 proper)

  • cli/internal/server/metrics.go — metricsServer: private Prometheus registry → OTel exporter → sdkmetric.MeterProvider, plus an admin http.Server whose mux serves only GET /metrics. The listener is bound eagerly in the constructor, so a port clash is fatal before the blog starts.
  • cli/internal/server/server.go / command.go / flagConsts.go — --metrics (off), --metrics-port (9090), --metrics-host (all interfaces). When enabled, the provider is passed via config.WithMeterProvider and the admin listener runs alongside srv.Run; on exit it drains with the same 10s budget and mp.Shutdown flushes last.
  • Dockerfile — EXPOSE 9090, entrypoint gains --metrics (same precedent as --health-checks); justfile run-image maps the port.
  • README — flags table rows with the 0.0.0.0 bind warning, a Metrics section under Docker (port, scrape config, the three PromQL queries), and a note that library users wire their own exporter. Also refreshed the feat(server): expose OpenTelemetry HTTP metrics from the library (Prometheus/Grafana groundwork) #92 godoc/README snippets that still used the pre-v3 ServerConfig literal.

No changes to pkg/server were needed, and git diff 18ad366..HEAD -- go.mod go.sum is empty — all 40-odd exporter modules are confined to cli/go.mod.

Verification

go vet, go test, go test -race across all three modules, plus gofmt -l — all clean. Integration module passes; container tests skip: Docker is unavailable in this sandbox, so TestServe_Metrics/TestServe_MetricsDisabled and the Dockerfile change are unproven locally and CI is the first real run. The sandbox also refused to execute the built binary, so there's no manual end-to-end smoke test — the unit tests cover the same paths in-process (real listener, real exporter, real ServeCommand flag parsing).

Worth a close look

  1. Shutdown ordering. The admin listener drains after srv.Run returns, not concurrently — each gets 10s, so a pathological shutdown could take 20s total. I preferred it: metrics stay scrapeable while the blog drains, and mp.Shutdown runs once all measured traffic has stopped. The alternative is a second signal.NotifyContext in the CLI.
  2. 13166fe fixes two ordering bugs I introduced: a leaked listener when server.New fails, and http.Server.Shutdown not closing a listener Serve hasn't registered yet. The explicit listener.Close() in Shutdown is deliberate.
  3. Merge conflict resolutions in pkg/server/server.go, pkg/config/serverOption.go and pkg/config/doc.go — I kept main's assets/sitemap work on v3's option plumbing and re-homed feat(server): expose OpenTelemetry HTTP metrics from the library (Prometheus/Grafana groundwork) #92's MeterProvider. If feat(server): expose OpenTelemetry HTTP metrics from the library (Prometheus/Grafana groundwork) (#92) #105 lands on main first, that resolution is the thing to re-check.
  4. Grafana dashboard — per the issue's suggestion I documented queries only, no dashboard JSON.

Changelog (#106)

✨ New Features

  • (server) record opentelemetry http server metrics
  • (cli) serve prometheus metrics on a separate admin port

🐛 Bug Fixes

  • (server) strip the method prefix from the http.route attribute
  • (cli) release the admin listener when startup or shutdown races

📚 Documentation

  • (cli) document Homebrew and archive installs, drop go install
  • (config) document As*Option lifting in package docs
  • (config) lift base options in godoc examples
  • document the variadic server options API
  • document the meter provider option and server metrics
  • document the metrics flags and the admin port

♻️ Refactoring

  • (cli) move CLI into a ./cli leaf module

🧪 Tests

  • (server) cover variadic option plumbing
  • (server) drop redundant generator blog root option
  • (server) cover blog root forwarding to the generator
  • (server) assert asset body bytes are counted once
  • (cli) cover the metrics admin listener and container scraping

🏗️ Build System

  • (cli) build the CLI module and publish a Homebrew cask
  • (docker) expose the metrics port and enable metrics in the image

🤖 CI

  • add updates for split cli and homebrew tap

🧹 Chores

  • merge v3-changes into rain/issue-94
  • merge rain/issue-92 into rain/issue-94

❓ Uncategorised!

harrydayexe and others added 28 commits June 12, 2026 19:45
Drop the positional *slog.Logger parameter from server.New and
server.Handler, completing the Phase 2 cleanup planned in #52.
Callers now supply a logger exclusively via config.WithLogger; the
fallback to slog.Default() is retained when no option is provided.

All internal call sites, tests, doc examples, and README updated.
The Phase-1 deprecated-path tests are removed.

BREAKING CHANGE: server.New(logger, posts, cfg) is now
server.New(posts, cfg); server.Handler(blog, logger, opts...) is now
server.Handler(blog, opts...).

Closes #55
Brings v3-changes up to date with main (27 commits: feeds, SEO metadata,
image support, health checks, cache control, the integration module).

Conflict resolution: v3's `feat(server)!: remove deprecated positional
logger from New and Handler` meets main's newer server code.

- pkg/server/server.go: keep v3's logger-free Handler call and main's
  new AssetsDir option.
- Test and doc call sites added on main still passed the positional
  logger to server.New and server.Handler; they now use the v3
  signatures, with the logger supplied through config.WithLogger where
  a test depended on it. internal/server/command_test.go used
  discardLogger(), which v3 deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split the goblog CLI out of the root module into a leaf module at ./cli
so CLI dependencies are no longer inherited by library consumers.

- cmd/goblog -> cli/cmd/goblog
- internal/  -> cli/internal/ (already CLI-only; no pkg/ importers)
- cli/go.mod replaces github.com/harrydayexe/GoBlog/v2 => ../, so the
  CLI always builds against the library at the same commit.

The root module sheds urfave/cli/v3, fatih/color, mattn/go-colorable and
mattn/go-isatty. pkg/... is untouched.

The CLI generator tests loaded templates through a relative os.DirFS path
into pkg/templates/default; they now use the embedded templates.Default,
which does not depend on the tree layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Dockerfile: download deps and build from ./cli; both go.mod files are
  copied up front so the `replace ../` resolves in the cached layer.
- justfile: build/install/run recipes target the CLI module; test, vet,
  fmt, vulncheck and mod-tidy now iterate every module instead of
  stopping at the root module's `./...`. Coverage is reported per module
  because `go tool cover` resolves sources through its own module.
- .goreleaser.yaml: `dir: cli`, shell completions generated in a before
  hook and shipped in every archive, and a `homebrew_casks` block that
  pushes to harrydayexe/homebrew-tap (`brews` is deprecated upstream).

The matching CI workflow changes are held back in a separate commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- README: the `## CLI` section now covers `brew install
  harrydayexe/tap/goblog` and release archives; `go install` no longer
  works because the CLI module is not published. Adds fish completion
  and notes that Homebrew and the archives ship completions.
- CONTRIBUTING: describe the three-module layout and why per-module
  commands are needed.
- CLAUDE.md: record the module boundary and the new install channels.
- version.go: the go install fallback path no longer applies.
- justfile: `run-gen` invoked a `gen` subcommand that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #93. Targets `v3-changes`.

Moves the `goblog` CLI out of the root Go module into a leaf module at
`./cli/`, and switches its distribution to a Homebrew tap plus release
archives and Docker. `go install` is no longer a supported install path.

> `v3-changes` was 27 commits behind `main`, so `main` was merged into
it first (commit `a572885`, pushed directly to `v3-changes`). This PR
sits on top of that merge.

## What changed

**Module split**

```
/go.mod              github.com/harrydayexe/GoBlog/v2        (library only)
/cli/go.mod          github.com/harrydayexe/GoBlog/v2/cli    (never published)
  replace github.com/harrydayexe/GoBlog/v2 => ../
  cmd/goblog/        moved from /cmd/goblog
  internal/          moved from /internal
/integration/go.mod  unchanged
```

`internal/` was already CLI-only — nothing under `pkg/` imported it — so
the whole tree moved rather than being reached across the module
boundary.

The root module sheds `urfave/cli/v3`, `fatih/color`,
`mattn/go-colorable` and `mattn/go-isatty`. `pkg/...` is untouched: `git
diff v3-changes -- pkg/` is empty.

**Build and release**

- `.goreleaser.yaml` builds with `dir: cli`, generates bash/zsh/fish
completions in a before hook, ships them in every archive, and publishes
a `homebrew_casks` block to `harrydayexe/homebrew-tap`. The issue
suggested `brews:`, but that is fully deprecated upstream as of
GoReleaser v2.16 and makes `goreleaser check` fail, so this uses
`homebrew_casks` instead.
- `Dockerfile` builds from `./cli/cmd/goblog`. Both `go.mod` files are
copied before `go mod download` so the `replace ../` resolves in the
cached layer.
- `justfile` recipes target the CLI module, and
`test`/`test-race`/`vet`/`fmt`/`vulncheck`/`mod-tidy` now iterate every
module instead of stopping at the root module's `./...`. Coverage is
reported per module, because `go tool cover` resolves sources through
the module it runs in.

**Docs**

README's `## CLI` section now documents `brew install
harrydayexe/tap/goblog` and release archives. CONTRIBUTING describes the
three-module layout; CLAUDE.md records the module boundary and the new
install channels.

## Follow-up needed

- **CI workflow changes are not in this PR** and must be applied
separately — see the review comment below for the exact diff. Without
them the CLI module is not vetted, tested or race-tested at all, and the
release job has no `HOMEBREW_TAP_GITHUB_TOKEN`.
- **The `harrydayexe/homebrew-tap` repo and its release token** still
need creating, and `HOMEBREW_TAP_GITHUB_TOKEN` adding as a repository
secret. Until then the `homebrew_casks` step will fail at release time.
- **A final `go install`-capable release** with a deprecation notice, if
you want one, as the issue suggests.

## Verification

- `just test`, `just test-race`, `just vet`, `just fmt-check` and
`addlicense -check` pass across all three modules.
- `goreleaser check` passes; `goreleaser release --snapshot` produces
archives containing the binary, LICENSE, README and `completions/`, and
a cask with correct `binary` and `*_completion` stanzas.
- The built binary reports the injected version and generates correctly.
- Docker was unavailable in this environment, so the image build is
verified by simulation — building `./cli/cmd/goblog` from a clean `git
archive` of the tree, and running `go mod download` in `cli/` with only
the two `go.mod`/`go.sum` pairs present.

## Note

`README.md` claims GPL-3.0 in its badge and License section, but
`LICENSE` is MPL 2.0 and every source header says MPL 2.0. Left alone as
out of scope, but worth fixing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!--- START AUTOGENERATED NOTES --->
### Changelog ([#95](#95))

#### 📚 Documentation
- (cli) document Homebrew and archive installs, drop go install

#### ♻️ Refactoring
- (cli) move CLI into a ./cli leaf module

#### 🏗️ Build System
- (cli) build the CLI module and publish a Homebrew cask

#### 🤖 CI
- add updates for split cli and homebrew tap

<!--- END AUTOGENERATED NOTES --->
WithBaseOption was deprecated in favour of BaseOption.AsGeneratorOption,
which all call sites already use. Remove it now that the public API can
break, and cover the replacement method directly in pkg/config.

BREAKING CHANGE: config.WithBaseOption has been removed. Call
BaseOption.AsGeneratorOption() on the base option instead, e.g.
config.WithLogger(logger).AsGeneratorOption().

Refs #57

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With WithBaseOption gone, BaseOption.AsGeneratorOption and its siblings are
the only way to pass a BaseOption to a constructor. The package overview
described BaseOption flowing into the specialised option types but never
named the methods that do the lifting, so add them to the "Option types"
section with a worked example for each.

Refs #57

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The godoc examples for WithBlogRoot and WithLogger passed a BaseOption
straight into generator.New and watcher.New, which only accept
GeneratorOption and WatcherOption. With WithBaseOption removed, AsGeneratorOption
and AsWatcherOption are the only route, so the examples now show them and
compile as written.

Also correct the WithBlogRoot doc comment, which said it returns an
"Option" rather than a BaseOption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Server.New now accepts `...config.ServerOption` instead of a
`config.ServerConfig` value, and the resolved values live in a
`config.ServerConfig` embedded in the Server.

ServerConfig is no longer a container of option slices: it holds the
resolved BlogRoot, Port, Host, Logger, CacheControlTTL, HealthChecks,
AssetsDir and TemplateDir values, plus the middleware chain and the
generator/renderer options forwarded to the internal generator and
template renderer.

BaseServerOption is renamed ServerOption to match GeneratorOption,
RendererOption and WatcherOption. Generator and renderer options reach
the server through new AsServerOption methods, and the template
filesystem through the new WithTemplateDir option.

BREAKING CHANGE: server.New takes variadic config.ServerOption values,
config.BaseServerOption is renamed to config.ServerOption, and
config.ServerConfig no longer carries Server, Gen, TemplateDir and
RendererOpts option slices.

Refs #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assert that New resolves defaults and every server option into the
embedded config.ServerConfig, and that generator, renderer and template
directory options supplied as ServerOption values reach the generator
and template renderer the server builds.

Refs #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrite the server, config and README examples for the new
server.New(posts, opts...) signature, and describe how base, generator
and renderer options are converted with AsServerOption.

Refs #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server forwards its resolved blog root to the generator, so the
option only needs supplying once.

Refs #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI passes --root-path as a server option only and relies on the
server forwarding its resolved blog root to the generator, but nothing
asserted that forwarding. Existing blog-root tests only exercise routing,
which is driven by the server's own BlogRoot, so dropping the forwarding
would silently emit wrong in-page link and image paths with every test
still green.

Assert the forwarded field in TestNew_OptionsResolveIntoServerConfig and
add TestNew_BlogRootForwardedToGenerator, which serves a post under a
custom blog root and checks the rendered page data and the rewritten
relative image path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #57

> [!NOTE]
> This pull request was written by
[rain](https://github.com/harrydayexe/rain) running Claude Code. It has
not been reviewed by a human. CI and an automated review pass run before
it is handed over.
>
> It targets `v3-changes` rather than the default branch:
`57-remove-deprecated-withbaseoption-wrapper-function` was already
linked to the issue, and its history says it was cut from `v3-changes`.

## Issue

[#57: Remove deprecated WithBaseOption wrapper
function](#57)

## What the agent reports

Pushed. Branch is two commits ahead of `v3-changes`.

## What changed

**`78c3c5a` — the removal (pre-existing on the branch, verified not
re-done).** Deletes `func WithBaseOption(BaseOption) GeneratorOption`
from `pkg/config/generatorOption.go` and adds
`pkg/config/generatorOption_test.go` covering the replacement
`BaseOption.AsGeneratorOption()` — that it lifts logger, blog root and
assets dir, and that it leaves the generator-specific func pointers nil
so the `else if` application chain can't pick a field the caller never
set.

**`204588f` — doc follow-up I added.** `pkg/config/doc.go`'s "Option
types" section said `BaseOption` flows into the specialised types "via
the embedded BaseOption" but never named the methods that do the
lifting. With the wrapper gone, `As*Option()` is the *only* route, so
the package overview described a capability with no stated way to reach
it. Added a short paragraph naming `AsGeneratorOption` /
`AsWatcherOption` / `AsServerOption` with one example each. This is the
`CLAUDE.md` rule that API changes carry doc updates; I kept it to the
one surface the removal actually invalidated.

## Verification

- `grep` for `WithBaseOption` across all `*.go` and `*.md`: no matches
anywhere in the repo, so no dangling call sites or stale docs.
- Per CI (`.github/workflows/test.yml` runs vet + test + `-race` per
module): `go vet ./...`, `go test ./...` and `go test -race ./...` all
pass for both `.` and `cli`. `go vet ./...` passes for `integration`
(its tests need Docker and were not run).
- `gofmt -l .` clean.
- `go doc ./pkg/config` renders the new section correctly.
- New tests run and pass under `-count=1` (not just cached).
- `addlicense -check` could not run — the binary isn't installed in this
environment. The new test file carries the MPL header matching its
siblings, and `doc.go` was edited in place, so the license check should
pass.

## For the reviewer

Two things worth a look:

**The doc examples are hand-verified, not compiled.** I initially wrote
`server.New(cfg, config.WithLogger(logger).AsServerOption())`, which is
wrong — `server.New` takes `(posts fs.FS, opts config.ServerConfig)`,
not variadic options. I checked the real signatures and corrected it to
construct a `ServerConfig` with a `Server:
[]config.BaseServerOption{...}` field. The `generator.New` and
`watcher.New` examples match their actual signatures. Since these live
in comments nothing enforces them, so a second read is worthwhile.

**Scope judgement.** The issue names only `WithBaseOption`. I did not
touch the sibling `As*Option` methods or the `AsOption()` methods on the
individual config types. The doc paragraph is the sole addition beyond
the literal removal.

The commit message on `78c3c5a` already carries a `BREAKING CHANGE:`
footer, which matters if release tooling derives the v3.0.0 notes from
commit trailers.
<!--- START AUTOGENERATED NOTES --->
### Changelog ([#102](#102))

#### 📚 Documentation
- (config) document As*Option lifting in package docs
- (config) lift base options in godoc examples

#### ❓ Uncategorised!
- feat(config)!: remove deprecated WithBaseOption wrapper

<!--- END AUTOGENERATED NOTES --->
…erConfig (#72) (#103)

Closes #72

> [!NOTE]
> This pull request was written by
[rain](https://github.com/harrydayexe/rain) running Claude Code. It has
not been reviewed by a human. CI and an automated review pass run before
it is handed over.
>
> It targets `v3-changes` rather than the default branch:
`72-make-server-take-a-variadic-array-of-options-and-store-config-in-serverconfig`
was already linked to the issue, and its history says it was cut from
`v3-changes`.

## Issue

[#72: Make Server take a variadic array of Options and store config in
ServerConfig](#72)

## What the agent reports

Pushed. Four commits on the branch, baselined on `v3-changes`.

## What changed

**`pkg/config`**
- `ServerConfig` is now the *resolved* server configuration —
`BlogRoot`, `Port`, `Host`, `Logger`, `CacheControlTTL`, `HealthChecks`,
`AssetsDir`, `TemplateDir`, plus `Middleware`, `GeneratorOpts`,
`RendererOpts`. It no longer carries
`Server`/`Gen`/`TemplateDir`/`RendererOpts` option slices.
- `BaseServerOption` → `ServerOption`, matching
`GeneratorOption`/`RendererOption`/`WatcherOption`.
- New plumbing so everything the old struct fields carried can travel in
the variadic list: `GeneratorOption.AsServerOption()`,
`RendererOption.AsServerOption()`, and a new `TemplateDir` config type
with `WithTemplateDir(fs.FS)` (+ `AsOption`).
`BaseOption.AsServerOption()` is unchanged.

**`pkg/server`**
- `func New(posts fs.FS, opts ...config.ServerOption) (*Server, error)`;
`Server` embeds `config.ServerConfig` instead of seven separate config
types and three unexported deferred-init fields. Field promotion means
`srv.Port`, `srv.Logger.Logger`, `srv.HealthChecks.Enabled` etc. still
read the same way.

**`cli/internal/server`** — builds a `[]config.ServerOption`; `runServe`
now takes `(ctx, postsPath, posts, watch, opts...)`. I dropped the
duplicate blog-root generator option there (and in one test):
`initialize` assigns `s.generator.BlogRoot = s.BlogRoot`
unconditionally, so applying it twice was a no-op.

**Tests** — all existing call sites migrated (`pkg/server`,
`cli/internal/server`, `integration`). New `pkg/server/options_test.go`
covers defaults, every option resolving into the embedded
`ServerConfig`, health-checks deferring generator construction, and
generator/renderer/template-dir options reaching the internal generator
and renderer (the last renders a custom template through a forwarded
`WithFuncs` entry). These fail without the change — the APIs they use
don't exist on the old signature.

**Docs** — `pkg/server/doc.go` (new "# Configuration" section),
`pkg/config/doc.go`, the option godoc examples, and README examples all
rewritten for the variadic form; added a short serve example to the
library section of README.

## Verification
`go vet ./...`, `go test ./...` and `go test -race ./...` for both `.`
and `cli`; `go vet ./...` for `integration`; `gofmt -l` clean; `go mod
tidy` a no-op in all three modules. I did **not** run the integration
suite (`just test-integration`) — it needs Docker; those tests compile
and were updated. A CLI smoke run was blocked by a permission prompt, so
end-to-end serve coverage rests on the CLI unit tests (index, post,
blog-root, watch-reload, feeds).

## For review
- The rename `BaseServerOption` → `ServerOption` and the `ServerConfig`
redefinition are the breaking parts; commit 1 is marked `feat(server)!`
with a `BREAKING CHANGE:` footer. Worth confirming this is the naming
you want for v3 given the issue wrote `config.ServerOptions`.
- `New` appends the server's own logger to `ServerConfig.GeneratorOpts`,
so that slice holds one more option than the caller passed.
- `GeneratorOption.AsServerOption` shadows the `AsServerOption` promoted
from its embedded `BaseOption`;
`config.WithLogger(l).AsGeneratorOption().AsServerOption()` therefore
routes to the generator, while `config.WithLogger(l).AsServerOption()`
configures the server (which forwards it anyway).
<!--- START AUTOGENERATED NOTES --->
### Changelog ([#103](#103))

#### 📚 Documentation
- document the variadic server options API

#### 🧪 Tests
- (server) cover variadic option plumbing
- (server) drop redundant generator blog root option
- (server) cover blog root forwarding to the generator

#### ❓ Uncategorised!
- feat(server)!: take variadic options and store resolved config

<!--- END AUTOGENERATED NOTES --->
Instrument pkg/server against the OpenTelemetry metrics API only, so
library consumers can attach their own MeterProvider (and therefore a
Prometheus exporter) without GoBlog depending on an exporter.

Three instruments follow the stable HTTP server semantic conventions:
http.server.request.duration, http.server.active_requests and
http.server.response.body.size. No separate request counter is added;
the histogram's _count series already answers "page hits".

Metrics are opt-in via config.WithMeterProvider. Without it the no-op
provider is used, no instruments are created and the middleware is not
installed at all, so requests pay nothing. The server deliberately does
not fall back to otel.GetMeterProvider().

http.route comes from the ServeMux pattern in r.Pattern, never
r.URL.Path, so bots scanning for unmatched paths collapse into a single
series instead of one per request. Unknown request methods collapse to
"_OTHER" for the same reason. The ResponseWriter wrapper implements
Unwrap, ReadFrom and Flush so http.ResponseController and the static
file copy fast path keep working.

Health probes to /healthz/* are answered before the handler stack and
are therefore never recorded.

The OTel metrics SDK is a test-only dependency, used with a manual
reader to assert on recorded measurements; only the API reaches the
build graph.

Refs #92

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Godoc for config.MeterProvider, config.WithMeterProvider and the new
BaseServerOption field ships with the code; this adds the surrounding
prose: a Metrics section in pkg/server/doc.go, the option listing in
pkg/config/doc.go, and a README section under Library covering how to
wire an OTel SDK plus Prometheus exporter and scrape it.

Also corrects the middleware section of pkg/server/doc.go, which
listed metrics as something to add via middleware.

Refs #92

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The static file path writes through the wrapper's ReadFrom rather than
Write, so cover it explicitly: double counting or zero counting there
would otherwise go unnoticed.

Refs #92

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ServeMux stores the whole matched pattern on the request, and a pattern
is "[METHOD ][HOST]/[PATH]", so the recorded attribute was
http.route="GET /posts/{postName}". Semantic conventions define
http.route as the matched path template alone and record the method
separately as http.request.method, so a stock query such as
http_server_request_duration_seconds_count{http_route="/posts/{postName}"}
matched nothing — defeating the point of using semconv names.

Split the method off the pattern the way ServeMux itself parses it, and
update the pinned attribute values. Also add an asset test that runs over
a real connection: httptest.ResponseRecorder does not implement
io.ReaderFrom, so every existing test took the wrapper's io.Copy
fallback, leaving the delegating branch — the only path that accounts for
static file bytes in production — unguarded against double counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#94 depends on the CLI leaf module from #93, which landed on v3-changes
rather than main. Merging it in gives this branch the ./cli module the
Prometheus exporter has to live in.

Conflicts resolved by keeping main's sitemap/robots and image dimension
work on top of v3's variadic server options API.

Refs #94
#94 needs the library-side instrumentation and config.WithMeterProvider
from #92, which has not landed on main yet.

WithMeterProvider is ported onto the variadic ServerOption API introduced
on v3-changes: the option becomes a ServerOption and MeterProvider joins
the resolved config.ServerConfig the server embeds.

Refs #94
--metrics wires the OpenTelemetry SDK and the Prometheus exporter into
the serve command and exposes /metrics on its own listener, defaulting to
port 9090. The exporter and its dependency tree live in cli/go.mod, so
library consumers are unaffected.

The admin listener never serves the blog and the blog listener never
serves /metrics, keeping operational data off the public port and out of
--blog-root prefixing. The port is bound before the blog starts, so a
clash fails the command outright rather than serving a blog whose metrics
are silently missing. Both listeners drain on SIGINT/SIGTERM/SIGHUP under
the same 10s budget, and the meter provider is shut down last so final
measurements flush.

Refs #94
Unit tests cover the semantic convention metric names on the admin port,
the admin port serving nothing but /metrics, /metrics staying off the
blog port, the fatal bind failure, no listener without --metrics, and
both listeners releasing their ports on shutdown.

The integration test scrapes the container and asserts the request count
reflects the traffic generated. Docker was unavailable in this
environment, so the container tests skip rather than run.

Refs #94
The image opts into --metrics the way it already opts into
--health-checks: scraping is the usual reason to run the container, and
port 9090 is unreachable unless the operator publishes it. `just
run-image` now maps it.

Refs #94
Adds the three --metrics flags to the serve table with the bind-address
warning, a metrics section under Docker covering the port, a scrape
config and the PromQL for page hits, error rate and p95, and a note in
the library section that library users wire their own exporter.

Also refreshes the metrics godoc examples carried over from #92 onto the
variadic server options API.

Refs #94
The listener was bound before the blog server was built, so a failure in
server.New returned without ever freeing the port. Shutdown also relied
on Serve having registered the listener, which is not guaranteed when the
process stops immediately after start.

Refs #94
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): serve Prometheus metrics from the CLI on a separate admin port

1 participant