skip to content

You maintain the Go HTTP metrics middleware every service imports. Should it expose a caller-supplied label hook or derive labels only from registered ServeMux patterns?

level: principalimportance: nice to knowfreq 26%

answer

  1. an exported hook is a permanent promise
  2. who pays for the label the team adds?
  3. everything unbounded lives on the request
  4. declare the value set, not the value
  5. give the need a home, not a refusal

basics

~10 s

Derive labels inside the library from the registered ServeMux patterns, method and status class. An exported hook taking the request lets any importing team return a user id, and you cannot withdraw it later.

solid answer

~40 s

Treat the label set as part of the library's exported API, because it is: once a `func(*http.Request) string` hook ships, every importing team's worst idea becomes your heap and your ingest bill, and you cannot withdraw the hook without breaking their dashboards. So the default shape derives everything itself -- `r.Pattern` filtered through the patterns registered via the library's own registration helper, the method, a coarse status -- with no per-request escape hatch. When a team genuinely needs another dimension, make them declare it at construction with its complete list of legal values, so an unknown value falls into the bucket and an implausible list fails at startup. High-cardinality context still belongs somewhere: log records and trace spans, which cost per event rather than forever.

go deeper

for a junior

Focus on the underlying fact: a label value chosen from the request can be anything a caller sends, while one derived from registered routes cannot. That difference is what the whole debate is about.

for a middle

Be ready to describe both API shapes concretely and to explain why a function taking the request cannot be bounded by the library, since every open-ended value lives on the request it is handed.

for a senior

Show how you would enforce the choice in code rather than in a convention: registration through the library, an allowlist built at startup, and a fixed bucket for anything unrecognised at request time.

for a principal

Own the tradeoff and its politics: an exported surface you cannot withdraw, a cost that lands on shared infrastructure rather than on the requesting team, a real destination for the need you are refusing, and a migration that proceeds service by service rather than on a flag day.

## What is actually being decided This is an API-shape decision with an organisational blast radius. The two candidate surfaces are: ```go // A: the library asks the caller what the label should be. func Middleware(next http.Handler, label func(*http.Request) string) http.Handler // B: the library decides, from what was registered through it. func (m *Metrics) Handle(pattern string, h http.Handler) func (m *Metrics) Wrap(next http.Handler) http.Handler ``` Shape A is friendlier on day one and is the one most teams ask for. It is also unrecoverable: an exported function type is a promise, importers write code against it, and removing it later means a coordinated change across every service — precisely the change nobody schedules. Shape B is more work to adopt, because callers must register routes through the library, but it makes the legal label set knowable at startup, which is the only property that actually bounds anything. ## Why the hook is the expensive choice A `func(*http.Request) string` gives the caller the whole request. Everything unbounded lives on the request: the path, the query, the headers, the wildcard values, the authenticated principal. A hook is therefore an invitation, and the invitation is accepted for good reasons — someone genuinely wants to know which customer is generating the errors. The result lands in your process as a map that grows with traffic, and in shared ingest as a series count nobody budgeted for. The team that wrote the hook does not see either cost; you do, and so does every other service sharing the same infrastructure. That asymmetry is the reason this is a platform decision rather than a team one. It is also the reason a flat refusal fails: the underlying need is real, so if the library only says no, teams will emit their own metrics beside it and you will have lost visibility as well as the argument. ## The shape I would ship 1. **Derive by default.** The library wraps registration: `m.Handle("GET /items/{id}", h)` records the pattern in an allowlist and delegates to the mux. At request time the label is `r.Pattern` when it is in the allowlist, and one fixed bucket otherwise. Method and a coarse status class come from the library too. No caller input, no hook, no way to get it wrong. 2. **Declared extra dimensions, closed set.** If a team needs one more dimension, it is declared at construction with its full list of permitted values — a region, a plan tier, a small enumeration. The library can then validate: a value outside the set is replaced by the bucket, and a declared set beyond a threshold fails at startup, where a failure is cheap and visible. 3. **A real answer for the rest.** Per-customer and per-request detail goes to logs and traces. Say it in the doc comment, next to the thing you are refusing, with the alternative named. 4. **Escape hatch with an owner.** If some service must have the hook, make it explicit, off by default, and reviewed — not because review scales, but because it puts a name on the decision. ## Judgment calls inside that - **How strict at startup?** Failing to boot on a bad label set is severe, and severity is the point: a service that will not start gets fixed in minutes, a service that quietly doubles ingest gets fixed in a quarter. I would fail hard on a declared set that is obviously open and log loudly on one that is merely large. - **Do you cap at request time too?** Yes, cheaply — an unknown value maps to the bucket. Startup checks cover what was declared; the request-time check covers what actually arrives. - **What about existing services?** The migration is the hard part, not the design. Shipping shape B alongside shape A and moving services one at a time is slower but is the only version that finishes; a flag day across an estate does not happen. - **Who can overrule you?** Someone owns the ingest budget and the incident load, and they can overrule a team that wants a per-customer breakdown, because the cost lands on shared infrastructure rather than on the requesting team. Make that ownership explicit rather than implicit in a code review, or the argument reopens with every new service. ## What a strong answer sounds like It names the irreversibility of the exported surface, separates the legitimate need from the dangerous mechanism, gives teams a place to put high-cardinality context instead of just forbidding it, and says where enforcement lives — startup declaration plus a request-time fallback — rather than relying on everyone remembering a rule. It also concedes the cost of shape B honestly: adoption friction, a migration, and a library that is slightly harder to drop into an existing service.

  • A team insists on a per-customer error rate. What do you actually give them?
    The error events, with the customer on them: structured logs or trace spans, where one more field costs one more field per event rather than a permanent counter per customer. If they need it aggregated continuously, that is a derived pipeline over those events, owned by whoever wants it, rather than a dimension every service pays for forever.
  • How do you enforce the rule for teams that do not register their routes through your helper?
    You cannot, and pretending otherwise is the mistake. Make the helper the path of least resistance, make the outer wrapper degrade safely — unknown pattern goes to the fixed bucket — and detect drift from outside by alerting on a service's own series count rather than by trusting every call site.
  • What is the strongest argument against your position?
    That it optimises for the platform's cost at the expense of the product teams' speed, and that a hook plus a review culture gets 90% of the benefit with none of the migration. It is a fair argument in a small estate with a few services; it stops working when nobody can name every service, which is exactly when the cost becomes real.
  • Does the answer change for a library your company publishes for outside users?
    It gets stricter. You no longer see the deployment, cannot take a hook back at all, and cannot fix a caller's mistake. Derive the labels, document the value set as part of the contract, and treat any change to it as a breaking change with a release note, because someone's alerting is keyed on those values.

saying these in an interview costs you the question

  • Ships the request-to-string hook because it is flexible
  • Refuses the high-cardinality need without offering logs or traces
  • Treats the exported label surface as easy to change later
  • Relies on a documented convention with no enforcement point
  • Plans a flag-day migration across every service at once
  • Assumes the team asking for the label pays for it