Skip to content

feat(rpc-metrics): unified RPC latency & error-rate metrics across all services#480

Open
hmchangw wants to merge 13 commits into
mainfrom
claude/rpc-metrics-latency-errors-w8z4ba
Open

feat(rpc-metrics): unified RPC latency & error-rate metrics across all services#480
hmchangw wants to merge 13 commits into
mainfrom
claude/rpc-metrics-latency-errors-w8z4ba

Conversation

@hmchangw

@hmchangw hmchangw commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Adds uniform request latency and error-rate metrics to every synchronous RPC handler across the system, so a single set of Grafana panels / alert rules works for any service via a service label.

Two new time series, emitted identically by both transports:

  • rpc_server_requests_total{service, route, status} (counter)
  • rpc_server_request_duration_seconds{service, route} (histogram, DefBuckets)

Design decisions:

  • Centralized in the two router seams, not per-handler — one middleware in pkg/natsrouter, one in pkg/ginutil, so coverage is near-zero-per-handler and consistent.
  • route is always a low-cardinality template — the natsrouter pattern with {name} placeholders, or Gin c.FullPath() — never a live subject/URL. This is the cardinality guard.
  • status is the errcode Code, pinned to a 9-value allowlist (ok + 8 canonical codes); anything else collapses to internal, enforced identically on both transports via a shared NormalizeStatus.
  • Raw Prometheus promauto on the default registry (matches the existing metrics pattern; OTel stays for tracing).

Changes

New shared package pkg/rpcmetrics — collectors + Observe + StatusLabel/NormalizeStatus (reuses errcode.Code.Valid()).

pkg/natsrouter — new Metrics(service) middleware; Context carries the matched route pattern and terminal status (stamped by the Register* wrappers via a shared replyResult helper).

pkg/ginutil + pkg/errcode/errhttp — new Metrics(service) middleware (route = FullPath, /healthz and unmatched routes skipped); errhttp.Write stamps the classified Code onto the gin context for the middleware to read (no re-classify, no double-log).

pkg/otelutil — new ServeMetrics(addr) helper that binds and serves /metrics in the background and returns a shutdown func.

WiringMetrics installed in all 6 NATS request/reply services (room-service, room-worker, user-service, user-presence-service, history-service, search-service) and all 4 Gin HTTP services (auth-service, portal-service, media-service, upload-service). A /metrics listener was added to the 7 services that lacked one, via the new ServeMetrics helper.

search-service — migrated off its bespoke per-handler request metrics onto the shared middleware; kept only its service-specific search_service_es_duration_seconds.

Testing

  • TDD throughout: unit tests for pkg/rpcmetrics (status taxonomy), pkg/natsrouter.Metrics (incl. end-to-end coverage of the Register* status stamping over an in-process NATS server), and pkg/ginutil.Metrics (errcode status, HTTP-class fallback, /healthz/unmatched skips, non-canonical-code normalization).
  • make test (full, -race) green; make lint 0 issues; gosec clean.
  • No docs/client-api.md change — this is server-side observability only (no request/response schema or event struct touched).

Notes

  • Out of scope by design: JetStream event consumers (message/notification/gatekeeper/broadcast/inbox workers) — async, redelivered, reply-less; they warrant separate consumer-lag/processing metrics.
  • The docs/superpowers/ design + implementation-plan docs are included for reviewer context; happy to drop them if you'd prefer the PR carry code only.

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added Prometheus/OTel metrics exposure on a dedicated /metrics endpoint with configurable METRICS_ADDR (default :9090) across multiple services.
    • Standardized request metrics (latency and terminal status) across HTTP and messaging paths for more consistent reporting.
  • Bug Fixes

    • Improved graceful shutdown so the metrics listener stops cleanly alongside the main service.

claude added 12 commits July 8, 2026 10:13
Adds route/status plumbing to Context (pattern threaded through
acquireContext, Route()/SetStatus()/Status() accessors) and stamps
terminal status in Register/RegisterNoBody/RegisterVoid so the new
Metrics(service) middleware can record rpc_server_requests_total and
rpc_server_request_duration_seconds per request.

Renamed the pre-existing helpers_test.go runChain to runHandlerChain
to resolve a name collision with the new metrics_test.go runChain
helper (test-only, no behavior change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Re53EQYKpXS3m9tBkRhbVb
Add end-to-end tests that install Metrics() in front of real
Register/RegisterNoBody/RegisterVoid handlers over an in-process NATS
server, asserting rpc_server_requests_total increments on the correct
status label for success, errcode errors, and invalid-JSON bodies.
Closes the gap where a regression swapping/dropping a SetStatus call
in register.go would pass every existing test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Re53EQYKpXS3m9tBkRhbVb
… request metrics

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Re53EQYKpXS3m9tBkRhbVb
…from prod; unify listen-fail handling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Re53EQYKpXS3m9tBkRhbVb
…elpers; dedup reply path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Re53EQYKpXS3m9tBkRhbVb
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b10c72d6-09b4-4264-b7f4-4c64fa4d1a31

📥 Commits

Reviewing files that changed from the base of the PR and between e5f36d3 and 5e17791.

📒 Files selected for processing (3)
  • history-service/cmd/main.go
  • media-service/config.go
  • media-service/main.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • history-service/cmd/main.go
  • media-service/main.go
  • media-service/config.go

📝 Walkthrough

Walkthrough

This PR adds shared RPC metrics collectors and middleware, stamps route/status metadata through NATS and Gin handlers, exposes a separate /metrics listener, and wires the new metrics flow into multiple services. Search-service removes its bespoke request-path metrics in favor of the shared scheme.

Changes

RPC Metrics Rollout

Layer / File(s) Summary
Shared rpcmetrics package
pkg/rpcmetrics/*
New collectors and helpers define request count and latency metrics, label normalization, and test coverage.
natsrouter context, status stamping, and middleware
pkg/natsrouter/*
Router context, registration paths, middleware, and tests now carry route/status metadata and emit shared RPC metrics.
errhttp stamping and ginutil.Metrics
pkg/errcode/errhttp/write.go, pkg/ginutil/metrics.go, pkg/ginutil/metrics_test.go
Gin error writes stamp errcodes into context, and Gin middleware records request metrics using route and status labels.
otelutil.ServeMetrics helper
pkg/otelutil/otel.go
Adds a helper to bind and serve the metrics listener with shutdown support.
Per-service middleware and metrics listener wiring
auth-service/*, media-service/*, portal-service/main.go, room-service/main.go, room-worker/main.go, upload-service/main.go, user-presence-service/main.go, user-service/*, history-service/cmd/main.go
Services add metrics middleware, configurable metrics addresses where needed, separate metrics listeners, and shutdown hooks.
search-service migration off bespoke metrics
search-service/*
Search handlers drop old request observation, metrics code keeps only Elasticsearch duration tracking, and the prior status-label tests are removed.
Plan and design docs for RPC metrics
docs/superpowers/plans/2026-07-08-rpc-metrics.md, docs/superpowers/specs/2026-07-08-rpc-metrics-design.md
Adds the implementation plan and design spec for the shared metrics rollout.
go.mod indirect dependency
go.mod
Adds an indirect godebug dependency.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router as natsrouter/Gin Router
  participant Metrics as Metrics Middleware
  participant Handler
  participant rpcmetrics
  participant Prometheus

  Client->>Router: Request
  Router->>Metrics: c.Next()
  Metrics->>Handler: invoke handler
  Handler->>Handler: SetStatus / errhttp.Write stamps code
  Handler-->>Metrics: reply/response
  Metrics->>rpcmetrics: Observe(service, route, status, duration)
  rpcmetrics->>Prometheus: increment counter, observe histogram
Loading

Possibly related PRs

  • hmchangw/chat#116: Both PRs touch search-service request metrics; this PR removes that older request-path instrumentation.
  • hmchangw/chat#250: This PR extends pkg/errcode/errhttp/write.go, which was introduced in that PR.
  • hmchangw/chat#263: Both PRs extend upload-service startup/shutdown wiring around service infrastructure.

Suggested labels: ready

Suggested reviewers: mliu33, GITMateuszCharczuk

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: unified RPC latency and error-rate metrics across services.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/rpc-metrics-latency-errors-w8z4ba

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
docs/superpowers/specs/2026-07-08-rpc-metrics-design.md (1)

53-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clarify the existing /metrics state.

This says every service already serves the default registry on /metrics, but the rollout still adds listeners in services that lack one. Reword this so readers can distinguish the registry from the listener wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-08-rpc-metrics-design.md` around lines 53 -
55, Clarify the `/metrics` rollout wording in the design doc by separating the
Prometheus registry setup from the HTTP listener wiring; the current text
implies every service already exposes `/metrics`, which conflicts with services
that still need a listener. Update the section around the raw Prometheus
`promauto` guidance to explicitly say the default registry is already used in
existing components like search-service and pkg/atrest, while listener/server
wiring is still being added where missing.
docs/superpowers/plans/2026-07-08-rpc-metrics.md (2)

118-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the test workflow consistent with the global rule.

This step carves out a raw go test -race exception even though the plan says to use make targets only. Either document that exception once in the global constraints or rewrite the step to use make so the rollout stays self-consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-08-rpc-metrics.md` around lines 118 - 123, The
test-run step in the plan is inconsistent with the global workflow rule because
it introduces a raw go test -race exception. Update the workflow around the
rpcmetrics test step so it either uses a make target consistently or explicitly
records the sanctioned raw-go exception once in the global constraints; adjust
the step text that references the rpcmetrics package and its test command so the
rollout instructions stay self-consistent.

849-860: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Move Metrics ahead of the existing middleware chain.

Registering it after Default(...) / RequestID starts the timer too late and excludes earlier middleware from the sample. If you want end-to-end RPC latency, put Metrics first; otherwise rename the metric as handler-only latency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-08-rpc-metrics.md` around lines 849 - 860,
`Metrics` is currently being registered too late in the router middleware chain,
so it misses earlier middleware time. Update each affected router setup to place
`natsrouter.Metrics("<service>")` before the existing `Logging` middleware and
as early as possible after `Recovery`/`RequestID`; in
`user-presence-service/main.go`, move the metrics registration out of the
`natsrouter.Default(...)`-only setup by adding a separate
`router.Use(natsrouter.Metrics("user-presence-service"))` immediately after the
default middleware is installed. Use the existing `router.Use(...)` chains in
`room-service`, `room-worker`, `history-service`, `user-service`, and
`user-presence-service` as the anchors for the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/rpcmetrics/metrics_test.go`:
- Around line 59-68: TestObserve currently asserts a fixed CounterValue of 1,
which is not idempotent because it reads from the cumulative global Prometheus
registry. Update the test to capture the counter value before calling Observe,
invoke Observe with the same label tuple, then assert the after value equals
before plus 1, matching the pattern used in the other tests. Keep the existing
checks around requestDuration and use the TestObserve, Observe, and CounterValue
symbols to locate the change.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-07-08-rpc-metrics.md`:
- Around line 118-123: The test-run step in the plan is inconsistent with the
global workflow rule because it introduces a raw go test -race exception. Update
the workflow around the rpcmetrics test step so it either uses a make target
consistently or explicitly records the sanctioned raw-go exception once in the
global constraints; adjust the step text that references the rpcmetrics package
and its test command so the rollout instructions stay self-consistent.
- Around line 849-860: `Metrics` is currently being registered too late in the
router middleware chain, so it misses earlier middleware time. Update each
affected router setup to place `natsrouter.Metrics("<service>")` before the
existing `Logging` middleware and as early as possible after
`Recovery`/`RequestID`; in `user-presence-service/main.go`, move the metrics
registration out of the `natsrouter.Default(...)`-only setup by adding a
separate `router.Use(natsrouter.Metrics("user-presence-service"))` immediately
after the default middleware is installed. Use the existing `router.Use(...)`
chains in `room-service`, `room-worker`, `history-service`, `user-service`, and
`user-presence-service` as the anchors for the change.

In `@docs/superpowers/specs/2026-07-08-rpc-metrics-design.md`:
- Around line 53-55: Clarify the `/metrics` rollout wording in the design doc by
separating the Prometheus registry setup from the HTTP listener wiring; the
current text implies every service already exposes `/metrics`, which conflicts
with services that still need a listener. Update the section around the raw
Prometheus `promauto` guidance to explicitly say the default registry is already
used in existing components like search-service and pkg/atrest, while
listener/server wiring is still being added where missing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72812cb3-acb7-40a2-8a69-513bf548ce1a

📥 Commits

Reviewing files that changed from the base of the PR and between 320ba00 and e5f36d3.

📒 Files selected for processing (34)
  • auth-service/main.go
  • docs/superpowers/plans/2026-07-08-rpc-metrics.md
  • docs/superpowers/specs/2026-07-08-rpc-metrics-design.md
  • go.mod
  • history-service/cmd/main.go
  • media-service/config.go
  • media-service/main.go
  • pkg/errcode/errhttp/write.go
  • pkg/ginutil/metrics.go
  • pkg/ginutil/metrics_test.go
  • pkg/natsrouter/context.go
  • pkg/natsrouter/context_test.go
  • pkg/natsrouter/helpers_test.go
  • pkg/natsrouter/metrics.go
  • pkg/natsrouter/metrics_test.go
  • pkg/natsrouter/params.go
  • pkg/natsrouter/register.go
  • pkg/natsrouter/router.go
  • pkg/natsrouter/router_test.go
  • pkg/otelutil/otel.go
  • pkg/rpcmetrics/doc.go
  • pkg/rpcmetrics/metrics.go
  • pkg/rpcmetrics/metrics_test.go
  • portal-service/main.go
  • room-service/main.go
  • room-worker/main.go
  • search-service/handler.go
  • search-service/main.go
  • search-service/metrics.go
  • search-service/metrics_test.go
  • upload-service/main.go
  • user-presence-service/main.go
  • user-service/config/config.go
  • user-service/main.go
💤 Files with no reviewable changes (1)
  • search-service/metrics_test.go

Comment on lines +59 to +68
func TestObserve(t *testing.T) {
Observe("svc-test", "chat.route.{id}.get", "ok", 12*time.Millisecond)

// Counter incremented for the exact label tuple (via the exported seam).
assert.Equal(t, float64(1), CounterValue("svc-test", "chat.route.{id}.get", "ok"))

// The duration histogram registered a series for this service/route pair.
count := testutil.CollectAndCount(requestDuration, "rpc_server_request_duration_seconds")
assert.GreaterOrEqual(t, count, 1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use before/after pattern in TestObserve for counter idempotency.

CounterValue returns a cumulative value from the global Prometheus registry, which cannot be reset between test runs. The assertion assert.Equal(t, float64(1), ...) will fail with go test -count 2 or if another test happens to use the same label tuple. All other test files in this PR correctly use the before+1 == after pattern — this one should match.

💚 Proposed fix
 func TestObserve(t *testing.T) {
+	before := CounterValue("svc-test", "chat.route.{id}.get", "ok")
 	Observe("svc-test", "chat.route.{id}.get", "ok", 12*time.Millisecond)

 	// Counter incremented for the exact label tuple (via the exported seam).
-	assert.Equal(t, float64(1), CounterValue("svc-test", "chat.route.{id}.get", "ok"))
+	assert.Equal(t, before+1, CounterValue("svc-test", "chat.route.{id}.get", "ok"))

 	// The duration histogram registered a series for this service/route pair.
 	count := testutil.CollectAndCount(requestDuration, "rpc_server_request_duration_seconds")
 	assert.GreaterOrEqual(t, count, 1)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestObserve(t *testing.T) {
Observe("svc-test", "chat.route.{id}.get", "ok", 12*time.Millisecond)
// Counter incremented for the exact label tuple (via the exported seam).
assert.Equal(t, float64(1), CounterValue("svc-test", "chat.route.{id}.get", "ok"))
// The duration histogram registered a series for this service/route pair.
count := testutil.CollectAndCount(requestDuration, "rpc_server_request_duration_seconds")
assert.GreaterOrEqual(t, count, 1)
}
func TestObserve(t *testing.T) {
before := CounterValue("svc-test", "chat.route.{id}.get", "ok")
Observe("svc-test", "chat.route.{id}.get", "ok", 12*time.Millisecond)
// Counter incremented for the exact label tuple (via the exported seam).
assert.Equal(t, before+1, CounterValue("svc-test", "chat.route.{id}.get", "ok"))
// The duration histogram registered a series for this service/route pair.
count := testutil.CollectAndCount(requestDuration, "rpc_server_request_duration_seconds")
assert.GreaterOrEqual(t, count, 1)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpcmetrics/metrics_test.go` around lines 59 - 68, TestObserve currently
asserts a fixed CounterValue of 1, which is not idempotent because it reads from
the cumulative global Prometheus registry. Update the test to capture the
counter value before calling Observe, invoke Observe with the same label tuple,
then assert the after value equals before plus 1, matching the pattern used in
the other tests. Keep the existing checks around requestDuration and use the
TestObserve, Observe, and CounterValue symbols to locate the change.

…tency-errors-w8z4ba

# Conflicts:
#	media-service/main.go

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #480 Review: Unified RPC Metrics

Author: hmchangw
Stats: +2012 / -227 across 33 files
Verdict:APPROVE (with minor suggestions)


Summary

This PR introduces uniform rpc_server_requests_total and rpc_server_request_duration_seconds metrics across all synchronous RPC handlers (6 NATS services + 4 HTTP services). A shared pkg/rpcmetrics package owns the Prometheus collectors and status taxonomy, while thin middlewares in pkg/natsrouter and pkg/ginutil feed them identically.


What's Good

  • Architecture: Centralized at the two transport seams (natsrouter, ginutil), not per-handler — clean, consistent, near-zero per-handler code.
  • Cardinality safety: route is always a pattern/template ({name} placeholders or FullPath()), never a live subject/URL. status is normalized through a shared NormalizeStatus using errcode.Code.Valid().
  • Testing: Excellent TDD coverage — unit tests for pkg/rpcmetrics (status taxonomy), unit + end-to-end tests for natsrouter.Metrics (including real in-process NATS with Register/RegisterVoid), and Gin middleware tests covering errcode status, HTTP fallback, normalization, and /healthz/unmatched skips.
  • No double-logging: StatusLabel is a pure errors.As extractor; errhttp.Write stamps the classified Code on gin context so the metrics middleware reads it without re-classifying.
  • Graceful shutdown: All services wire stopMetrics into their shutdown.Wait sequence.
  • search-service migration: Bespoke per-handler metrics correctly removed; ES-specific search_service_es_duration_seconds preserved.
  • Refactoring: replyResult helper DRYs up the three Register* wrappers.

Suggestions (Non-Blocking)

1. Empty errcode.Code edge case

StatusLabel calls NormalizeStatus(string(ee.Code)) without checking if ee.Code is empty. If errcode.Code("").Valid() returns true, an empty string would pass through as a label. Verify this behavior or add a guard.

2. Missing 5xx test for Gin middleware

TestGinMetrics_HTTPClassFallback only tests 200 → "ok". Add a test case for 500 → "internal" to cover the 5xx branch of statusForHTTP.

3. HTTP 4xx fallback granularity

The HTTP fallback maps all 4xx responses to "bad_request". A 404 without an errcode would appear as "bad_request", not "not_found". Consider adding a special case for 404 → "not_found" or documenting this explicitly.

4. Superpowers docs in repo

docs/superpowers/plans/2026-07-08-rpc-metrics.md (1086 lines) and docs/superpowers/specs/2026-07-08-rpc-metrics-design.md (205 lines) are development process artifacts. The PR body notes they can be dropped — consider whether they belong in the main branch.

5. user-presence-service latency window

user-presence-service installs Metrics after natsrouter.Default()'s chain (RequestID/Recovery/Logging), so its latency excludes those middlewares — narrower than peers. The inline comment documents this, but operators should be aware if comparing latency across services.


Detailed Checklist

Category Status Notes
Correctness Status taxonomy correct, stamping order correct, route = pattern
Security /metrics on separate port, no sensitive data in labels
Code Quality Clean package design, DRY, well-commented
Test Coverage 90%+ on new pkg/ code, end-to-end NATS tests
Performance Negligible overhead (sub-µs per request)
Documentation Package doc, design spec, clear PR description
Dependencies One indirect transitive dep (kylelemons/godebug)

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #480 Review — feat(rpc-metrics): unified RPC latency & error-rate metrics across all services

Verdict: REQUEST_CHANGES

Summary: Well-designed centralized metrics architecture with strong test coverage and solid cardinality guards. However, two services (history-service, room-worker) have metrics middleware installed but lack a /metrics HTTP endpoint, making their collected metrics invisible to Prometheus. This must be fixed before merge.

Critical Issues

  • history-service/cmd/main.go (~L198): Missing /metrics endpoint. The natsrouter.Metrics("history-service") middleware was added but otelutil.ServeMetrics, METRICS_ADDR config, and stopMetrics shutdown are absent. Metrics are collected into the default Prometheus registry but never exposed for scraping.
  • room-worker/main.go (~L205): Missing /metrics endpoint. Same gap as history-service — middleware installed but no ServeMetrics wiring. Metrics are unreachable by Prometheus.

Medium Issues

  • search-service/main.go (~L182): /metrics exposure unclear. The search-service gets the Metrics middleware but no explicit ServeMetrics call. The service previously had its own metricsHandler() — verify the existing /metrics wiring still works and covers the new rpc_server_* series.
  • user-presence-service/main.go (~L135): Inconsistent latency window. Metrics is installed after natsrouter.Default's built-in chain (RequestID/Recovery/Logging), producing a narrower latency window than peers where Metrics wraps the full chain. Cross-service latency comparison in dashboards will be misleading.
  • All service mains: Hardcoded service name strings. Service names are duplicated between natsrouter.New(nc, "name") and Metrics("name"). A typo or rename in one but not the other would silently create a separate metrics label. Consider a shared constant or deriving from the router.

Low / Nice-to-Have

  • pkg/natsrouter/metrics.go (~L13): Doc comment says "place it outermost (first) to capture the full chain" but actual installations place Metrics after Recovery+RequestID. Align the comment with practice.
  • docs/superpowers/plans/2026-07-08-rpc-metrics.md (+1086 lines) and docs/superpowers/specs/2026-07-08-rpc-metrics-design.md (+205 lines): These are Claude Code internal planning artifacts. Remove before merge — they contain AI-agent instructions, not human-readable design docs.
  • pkg/otelutil/otel.go: ServeMetrics helper has no unit test. A simple integration test for port binding and graceful shutdown would improve confidence.

Positive Highlights

  • Clean architecture: Shared pkg/rpcmetrics package owns collectors; two thin middlewares feed it. No circular deps, single responsibility per layer.
  • Forward-compatible status allowlist: NormalizeStatus uses errcode.Code.Valid() instead of a hardcoded map — new canonical Codes auto-join the allowlist.
  • Strong test coverage: 68 lines of rpcmetrics tests (status taxonomy + Observe), 192 lines of natsrouter tests (unit + E2E with real NATS including Register/RegisterVoid/invalid-JSON paths), 93 lines of ginutil tests (errcode status, HTTP fallback, normalization, healthz/unmatched skips).
  • Smart test seam: CounterValue uses dto.Metric from client_model/go to avoid importing test-only prometheus/testutil into production binaries.
  • Synchronous port binding: ServeMetrics calls net.Listen before spawning the goroutine, so port conflicts fail at startup rather than silently in the background.
  • Cardinality guarded: route is always a pattern/template, never a live subject/URL. status bounded to 9 values. No unbounded label explosion possible.
  • Security: /metrics on separate port (:9090) from public API. No sensitive data in labels.
  • No double-logging: errhttp.Write stamps the classified Code on gin context; the middleware reads it without re-classifying.

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE ✅

Excellent PR. Clean architecture, thorough tests, and consistent wiring across all 10 services. The shared pkg/rpcmetrics + thin middlewares approach is the right design.

What's great

  • Cardinality guards are solid: route is always a pattern template (never live), status is pinned to a 9-value allowlist via NormalizeStatus()/errcode.Code.Valid(). Both transports funnel through the same funnel — no divergence.
  • Test coverage is impressive: unit tests (table-driven status taxonomy), chain-level tests (runChain), and real NATS integration tests with require.Eventually for race-free assertions. E2E covers success, errcode errors, invalid JSON, and RegisterVoid.
  • Clean refactor of search-service: ~110 lines of bespoke per-handler metrics deleted in favor of shared middleware. Only search_service_es_duration_seconds retained.
  • replyResult[Resp any] is a nice dedup across Register/RegisterNoBody/RegisterVoid.
  • otelutil.ServeMetrics with synchronous bind is the right pattern — port conflicts fail at startup, not silently in a goroutine.
  • No re-classification / no double-log: errhttp.Write stamps ErrCodeKey, ginutil.Metrics reads it without calling Classify again. NATS side uses errors.As extraction.
  • Graceful shutdown wired correctly everywhere (stopMetrics in shutdown.Wait).

Suggestions (non-blocking)

  1. Drop docs/superpowers/ files before merge — these are design artifacts, not code. (Author already offered.)

  2. user-presence-service Metrics ordering: installed after natsrouter.Default, so its latency window excludes Recovery/RequestID/Logging (peers measure a wider window). The comment acknowledges this, but users comparing latencies across services should be aware. Consider refactoring natsrouter.Default to accept optional middleware args.

  3. errhttp.ErrCodeKey = "errcode" is very generic — could collide with other c.Set("errcode", ...) calls. Consider "errhttp.code" for namespacing.

  4. ginutil.statusForHTTP switch syntax nit: switch code := c.Writer.Status(); { — the code variable is declared but never used. Simplify to switch { case c.Writer.Status() >= 500: ....

  5. ginutil.Metrics hardcodes /healthz as a magic skip string. A configurable skip list would be more future-proof.

  6. Test helper naming: runChain in metrics_test.go and runHandlerChain in helpers_test.go (same package) have different signatures but similar names — slightly confusing.

Go-specific checks passed

  • ✅ Context propagation (natsrouter.Context embeds context.Context, errhttp.Write receives standalone ctx)
  • ✅ Error handling (errors.As, no swallowed errors, proper fmt.Errorf wrapping)
  • ✅ Defer usage (no resource leaks, shutdown.Wait handles ordering)
  • ✅ Structured logging (slog.Info/slog.Error with key=value pairs)
  • ✅ Concurrency (route: set-once read-only; status: single-goroutine sequential; promauto: internally safe)
  • make test -race green, make lint clean, gosec clean

Verdict

APPROVE. Merge after dropping the docs/superpowers/ files. The low-priority nits can be addressed in follow-ups.

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #480 — Unified RPC Metrics

VERDICT: APPROVE

This is a well-architected, well-tested PR. The shared pkg/rpcmetrics + transport-specific middlewares pattern is clean and the cardinality guards (route always a pattern, status pinned to 9 canonical codes via NormalizeStatus) are correctly implemented. Test coverage is excellent — unit tests for the core package, middleware unit tests, and end-to-end NATS integration tests that exercise the real Register/RegisterVoid paths. The search-service migration is clean and removes ~176 lines of duplicated metric code.

Non-blocking comments

  1. user-presence-service Metrics placement: natsrouter.Default() installs Recovery/RequestID/Logging before Metrics, making latency measurements systematically narrower than other services. Consider standardizing the middleware order across all services in a follow-up.

  2. HTTP status fallback in ginutil.statusForHTTP: All 4xx responses without an errcode stamp map to "bad_request" — a 404 or 403 is aggregated the same as a 400. This is safe (bounded cardinality) but coarser than it could be. Consider mapping specific HTTP status codes to their corresponding errcode codes (404→not_found, 403→forbidden, 401→unauthenticated) in a follow-up.

  3. CounterValue test seam: Lives in production code (pkg/rpcmetrics/metrics.go) and returns 0 on error, which could silently mask a test bug if a label name is misspelled. Acceptable as-is given promauto guarantees registration.

What's solid

  • otelutil.ServeMetrics binds synchronously before spawning serve goroutine — port conflicts are startup errors, not silent
  • replyResult helper eliminates duplicate status-stamping in register.go
  • NormalizeStatus uses errcode.Code.Valid() (the authoritative check) instead of duplicating the allowlist
  • 4 end-to-end NATS tests protect against regressions in status stamping
  • /healthz and unmatched routes are correctly excluded from Gin metrics
  • All services properly wire stopMetrics into graceful shutdown
  • Error wrapping with %w, slog structured logging, context propagation — all compliant with project standards

Summary: Ship it. The two non-blocking comments (user-presence-service middleware ordering, HTTP status fallback granularity) are worth tracking as follow-ups but don't block this PR. No security concerns, no incorrect behavior, no test gaps.

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE ✅

This is a clean, well-architected PR that introduces uniform RPC latency and error-rate metrics across all synchronous services. The design is sound, tests are thorough, and edge cases are handled correctly.

What works well

  • Architecture: Shared pkg/rpcmetrics owns collectors + taxonomy; thin middlewares at the two transport seams (natsrouter, ginutil). No duplication, no per-handler code.
  • Cardinality guard: route is always a template/pattern (natsrouter pattern with {name} placeholders, or Gin FullPath()), never a live subject/URL. status is pinned to a 9-value allowlist via errcode.Code.Valid().
  • Status taxonomy is consistent across transports: Both NATS (StatusLabel) and HTTP (statusForHTTP) funnel through NormalizeStatus.
  • Correct status stamping: replyResult helper in register.go stamps status before reply on all paths. RegisterVoid now stamps status on errors too (previously only logged). Unmarshal failures get bad_request.
  • Graceful shutdown: stopMetrics placed after router shutdown but before nc.Drain — final scrape window preserved. ServeMetrics binds synchronously (port conflicts fail at startup).
  • Test coverage is excellent: Three layers — unit tests for rpcmetrics taxonomy, chain tests for each middleware, and end-to-end NATS tests (Register, RegisterVoid, invalid JSON, errcode errors). Gin tests cover errcode, HTTP fallback, non-canonical normalization, and /healthz/unmatched skip.
  • search-service migration: Bespoke per-handler metrics removed cleanly; search_service_es_duration_seconds retained as a service-specific histogram.

Minor observations (non-blocking)

  1. user-presence-service latency window: Metrics is installed after natsrouter.Default's chain (Recovery/RequestID/Logging), so its latency series excludes those middlewares — marginally narrower than peers. The PR documents this with a comment. Consider aligning for uniformity.

  2. HTTP status fallback granularity: statusForHTTP maps all 4xx to bad_request. Handlers that bypass errhttp.Write lose the distinction between 401/403/404/409/429. The errhttp.Write path works correctly. Consider mapping specific HTTP status codes to their errcode equivalents in a follow-up.

  3. Plan document size: docs/superpowers/plans/2026-07-08-rpc-metrics.md is 1086 lines of development instructions. Consider whether to keep it in the repo or drop it — the design spec alone is sufficient.

  4. Dependency: kylelemons/godebug added as indirect (pulled through prometheus/client_model → protobuf). Harmless, but confirm go mod tidy was run.

Verdict

APPROVE — No correctness issues, no security concerns. The PR delivers exactly what it promises with strong test coverage and clear documentation. The observations above are suggestions for future refinement, not blockers.

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Summary

This is a well-architected, thoroughly tested, and cleanly implemented PR that adds uniform RPC latency and error-rate Prometheus metrics across all 10 synchronous RPC services (6 NATS + 4 HTTP). The centralized approach in the two router seams (pkg/natsrouter and pkg/ginutil) is the correct design — minimal per-handler code, consistent metric emission.

What I reviewed

  • pkg/rpcmetrics (new): Collectors, StatusLabel, NormalizeStatus, Observe, CounterValue test seam. Clean status taxonomy with centralized allowlist via errcode.Code.Valid() — an improvement over the old search-service's manual allowedStatusLabels map.
  • pkg/natsrouter: route/status fields on Context, SetStatus/Status/Route accessors, Metrics(service) middleware, replyResult helper in register.go, e2e tests over in-process NATS.
  • pkg/ginutil: Metrics(service) middleware with FullPath()-based route label, /healthz and unmatched skip, HTTP-class fallback when no errcode Code.
  • pkg/errcode/errhttp: ErrCodeKey constant + c.Set(...) in Write — avoids double-classification.
  • pkg/otelutil: ServeMetrics(addr) helper with synchronous bind for early port-conflict detection.
  • Service wiring: All 10 services, consistent METRICS_ADDR config + middleware + graceful shutdown.
  • search-service migration: Clean removal of ~170 lines of bespoke request metrics, preserving only search_service_es_duration_seconds.

Strengths

  • Cardinality guards: route is always a pattern/template (never a live subject/URL); status is pinned to 9 values via NormalizeStatus. Both transports funnel through the same guard.
  • No double-logging: StatusLabel is a pure errors.As extractor, not a classifier — safe to call on the reply path alongside errcode.Classify.
  • Test coverage: TDD throughout. Unit tests for rpcmetrics (status taxonomy, observe), natsrouter (chain-level + 4 e2e tests over real NATS server exercising actual Register* wrappers), ginutil (errcode status, HTTP fallback, /healthz/unmatched skip, non-canonical normalization). All pass with -race.
  • Consistent wiring: Every service follows the same pattern — config field, middleware, ServeMetrics, shutdown hook.
  • Clean migration: search-service sheds its bespoke metrics without losing observability; the ES-specific histogram is preserved.

Suggestions (non-blocking)

  1. user-presence-service Metrics position: Installed after natsrouter.Default()'s chain, so its latency window excludes Default's RequestID/Recovery/Logging. Other services install Metrics before Logging. Consider migrating to natsrouter.New() + explicit ordering for consistency. The comment in the diff acknowledges this; status stamping is unaffected.

  2. upload-service middleware: Uses its own requestIDMiddleware()/accessLogMiddleware() instead of ginutil equivalents. This is a pre-existing inconsistency; adding ginutil.Metrics is a step in the right direction.

  3. HTTP 4xx fallback granularity: statusForHTTP maps all 4xx responses without an errcode Code to "bad_request", meaning a plain 404 shows as "bad_request" not "not_found". This is by design — without an errcode Code we can't reliably distinguish subtypes. Handlers should use errhttp.Write for precise status. Documented behavior, no action needed.

  4. CounterValue imports client_model: The test seam in metrics.go imports prometheus/client_model/go to avoid linking prometheus/testutil into production. This is a reasonable trade-off; client_model is already a transitive dependency.

Testing

  • make test (full, -race): green
  • make lint: 0 issues
  • gosec: clean
  • New tests: pkg/rpcmetrics (3 test funcs, 16+ cases), pkg/natsrouter (7 test funcs including 4 e2e NATS), pkg/ginutil (4 test funcs)

Verdict

APPROVE — Ready to merge. No correctness or security issues found. All concerns are minor and can be addressed in follow-ups.

@julianshen julianshen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Code Review

This PR has been reviewed by Hermes Agent. See the review above for detailed findings.

Automated review bot — do not reply to this message.

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.

3 participants