feat(server): expose OpenTelemetry HTTP metrics from the library (Prometheus/Grafana groundwork) (#92) - #105
harrydayexe wants to merge 4 commits into
Conversation
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>
Automated reviewI've read the full diff, the surrounding server/handler code, the The core design is solid:
Agent responsePushed to Review points1. Confirmed by running a I added a Updated the six pinned values in 2. The Added
I verified the new test does what it claims by mutating Also factored the thrice-repeated histogram unwrapping into a Verification
Posted by rain. Both halves were written by Claude Code; treat them as a starting point for human review, not a substitute for it. |
Closes #92
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
#92: feat(server): expose OpenTelemetry HTTP metrics from the library (Prometheus/Grafana groundwork)
What the agent reports
Pushed. Here's the handover.
What changed
pkg/config/serverOption.go— newMeterProvidertype (struct{ MeterProvider metric.MeterProvider }),WithMeterProvider(mp)constructor, andWithMeterProviderFuncadded toBaseServerOption. Also anEnabled()method onMeterProvider, mirroring the existingAssetsDir.Enabled(): it reports false for a nil provider or the no-op provider, so the server can skip instrumentation entirely rather than paying for no-op recording.pkg/server/metrics.go(new) — three instruments built fromsemconv/v1.43.0/httpconv, which supplies the semconv names, units, descriptions and histogram bucket boundaries for free:ServerRequestDuration,ServerActiveRequests,ServerResponseBodySize. Plus the recording middleware, method normalisation, and theResponseWriterwrapper (WriteHeader/Write/ReadFrom/Flush/Unwrap).pkg/server/server.go—config.MeterProviderembedded intoServer, applied in the option chain; instruments built inNew(error returned if creation fails); middleware installed as the outermost layer inrefreshHandler, outside cache-control.Docs — godoc on every new symbol, a
# Metricssection inpkg/server/doc.go, the option listing inpkg/config/doc.go, and a README section under Library. The stale "metrics" mention in the middleware section ofpkg/server/doc.gois corrected.Verification
addlicenseisn't installed in this environment, sojust check-licensedidn't run; the new files carry the MPL header matching every other file.I confirmed the tests fail without the fix by temporarily swapping
r.Patternforr.URL.Path:TestMetrics_UnmatchedPathsBoundedreported "500 unmatched requests produced 500 time series, want 1" and all sevenTestMetrics_Attributessubtests failed.Worth a close look
Dependency count came in lower than the issue estimated.
go list -depsover./pkg/server ./pkg/config ./cmd/goblogpulls in two new modules, not five:go.opentelemetry.io/otelandotel/metric(pluscespare/xxhashtransitively).otel/trace,auto/sdkand the logr pair are not build dependencies at v1.46.0. The SDK is confirmed absent from the build graph.One side effect the acceptance criteria didn't anticipate: the test-only SDK raised
golang.org/x/sysfrom v0.25.0 → v0.47.0 in the rootgo.mod. That's a version bump on an existing indirect dep (used bygo-isatty), not a new module, but it does reach consumers through MVS.r.Patternsurvives both wrappers — verified by test, not just by reading:ServeMux.ServeHTTPassignsr.Patternin place on the same request,NewStripHTMLExtensionmutates rather than clones, andhttp.StripPrefixclones only after the mux has already set the field on the outer request. The asset subtest assertinghttp.route == "GET /images/"is what pins this.ReadFromon the wrapper goes slightly beyond the literal acceptance criterion.Unwrapalone does not preserve theio.ReaderFromfast path (io.Copytype-asserts the writer it is handed), so a bareUnwrapwrapper would have downgraded every asset response to 32KiBWritechunks. ForwardingReadFromkeeps it, andwriteOnlypreventsio.Copyrecursing back into our ownReadFromon the fallback path.TestMetrics_AssetBodySizeguards the counting.Flushalways satisfieshttp.Flusher, even when the wrapped writer doesn't support flushing, in which case it no-ops instead of the writer reporting unsupported. This mirrorsotelhttp's wrapper; GoBlog never streams, so it's theoretical, but it is a behaviour difference worth knowing about.Deferred, per the issue's open questions
postattribute — not included. The issue itself suggests a separate opt-in option; it isn't in the In-scope list, so it wants its own issue.Handler()support — documented as a limitation inpkg/server/doc.go(callers mounting the exportedHandlershould applyotelhttpthemselves), which is what the issue recommended.No
/metricsendpoint, exporter, or CLI flag — all explicitly out of scope and left for #93/#94. The API acceptsmetric.MeterProvider, so #94 can attach an SDK without touchingpkg/server.Changelog (#105)
✨ New Features
🐛 Bug Fixes
📚 Documentation
🧪 Tests