Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins - #5956
Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins#5956glageju wants to merge 37 commits into
Conversation
The D8 ownership labels stacklok.component and stacklok.product are meant to be frozen per-series identity, but they were applied to the OTEL resource before CustomAttributes and WithFromEnv. Because resource detectors merge last-wins, a CLI custom attribute or OTEL_RESOURCE_ATTRIBUTES entry for either key silently overrode the frozen values, letting stacklok_component and stacklok_product drift in the Prometheus output. Apply the two reserved attributes as the final resource detector so they always win over user- and env-supplied attributes.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5956 +/- ##
==========================================
+ Coverage 72.62% 72.64% +0.01%
==========================================
Files 736 736
Lines 76359 76447 +88
==========================================
+ Hits 55457 55533 +76
- Misses 16962 16997 +35
+ Partials 3940 3917 -23 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Multiple review passes on the metrics-standardization migration (proxy + vMCP -> stacklok.* semconv vocabulary) surfaced gaps left by the cutover: - Backend health gauge leaked series for backends removed from the registry (e.g. via list_changed) since it tracked membership in an internal map that only grew. MonitorBackends now takes the BackendRegistry directly and re-derives live backends from registry.List on every collection. - The health gauge collapsed BackendHealthStatus's five states into a boolean, losing degraded/unknown/unauthenticated information the registry already carries before any request completes. The gauge now emits one point per real status value. - A construction failure in core.New occurring after MonitorBackends registered its gauge callback (audit config, workflow validation, health monitor setup) never unregistered it, leaking the callback against the shared meter provider for every failed New attempt. - Docs (observability.md, virtualmcpserver-observability.md, telemetry-migration-guide.md) and four Grafana example dashboards still referenced deleted/renamed legacy metric and label names. - Minor cleanup: an unnamed "not_found" outcome string, inconsistent receiver types on telemetryBackendClient, and a discarded metric registration handle. Adds unit test coverage for the health gauge's registry-driven membership, its multi-state reporting, and a regression test proving core.New unregisters the callback on every post-registration failure path (verified by temporarily reverting the fix and confirming the new test fails). Generated with [Claude Code](https://claude.com/claude-code)
The metrics-standardization migration lost information the deleted legacy metrics carried and left downstream artifacts partially migrated: mcp.client.operation.duration had no backend identity label, three Grafana dashboards still queried deleted metric names, and the new stacklok.vmcp.mcp_server.health gauge never consulted the live health monitor, so it could disagree with capability filtering. - Add mcp_server to mcp.client.operation.duration's attributes - Migrate remaining stale panels in 3 Grafana dashboards to the semconv metric/label vocabulary - Thread a live health.StatusProvider into the backend-health gauge via a new HealthProviderSetter, matching filterHealthyBackends' precedence (live provider > recorded outcome > registry snapshot), and normalize an empty HealthStatus to healthy instead of reporting every state as 0 - Cover all 5 of New()'s post-registration error paths for backend health callback unregistration, not just one - Hoist the duplicated componentName constant into providers.ComponentName - Fix Close()'s stale doc comment and rename unregisterHealthOnError to unregisterHealthLogged (it also runs on the normal shutdown path) - Document that NewMeteredTokenCache has no production caller yet - Add the vMCP optimizer and mcp_server.health rows to the migration guide's metric mapping table Generated with Claude Code Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
error_type on the semconv metrics is only set for HTTP >=500, so the dashboard error-rate panels' error_type!="" filter silently excluded 4xx failures (authz denials, classification rejects) the old status!="success" caught. Switch those panels to the HTTP duration metric's status-code label, which sees every request. The migration guide's request-volume guidance summed mcp_server_operation_duration_seconds_count and http_server_request_duration_seconds_count, but every MCP-method-bearing request increments both, double-counting them. Use the HTTP duration metric alone for total volume. Generated with Claude Code Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Grafana total-RPS panels queried the MCP-only histogram, undercounting transport-level traffic; the migration guide pointed at a label that doesn't exist on its replacement metric; several D8/build_info/health-gauge doc sections contradicted the actual implementation; and the proxy middleware could panic on a rare instrument-registration failure. Also drops the token-cache metrics decorator shipped with no production caller.
|
Addressed the seven inline findings in 46864d8: mcp_server label restored on the two proxy request metrics, SSE connection duration split into its own histogram, the OwnershipLabels escaping split fixed, migration guide mapping table corrected, and the two small doc/comment issues cleaned up. On the cutover strategy: I don't have a counter-argument to D13 only covering the 6 deletions, not the 18 renames. Dual-emitting the renamed metrics behind a flag defaulting to on, keeping the 6 deleted twins one more release, and adding a startup WARN is a reasonable path and consistent with how span attributes are already handled via --otel-use-legacy-attributes. That's a design change beyond what I can fold into responses to this review's inline comments, so I'm not implementing it here. Will follow up on scoping that as a separate change. |
The test asserted http_response_status_code="200" >= 1 to prove an authorized SSE tool call succeeded. That assertion was only ever satisfied by the SSE connection-open GET's default 200 status leaking into the same metric family — an authorized tools/call POST over SSE transport gets 202 Accepted (the result arrives async over the stream), never 200. Splitting SSE connection duration into its own histogram (46864d8) removed that incidental 200, exposing the gap. Drop the 200 assertion and make the tools/call-count assertion (mcp_server_operation_duration_seconds_count) unconditional — it already existed as the correct signal but was gated behind a non-fatal substring check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ChrisJBurns
left a comment
There was a problem hiding this comment.
Cutover strategy: remove legacy attributes, but keep renamed metrics behind a flag
Seconding @aponcedeleonch's request — this is the one thing I'd want resolved before merge, and I want to add some evidence for why the rename half is the more dangerous half.
The position: retiring the legacy span attributes is fine. That surface already has a compatibility story — dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Users get a flag, a window, and a documented path.
Renaming metrics has none of that today, and it should get the same treatment:
- Dual-emit the renamed metrics under both old and new names, behind an
--otel-legacy-metricsflag defaulting to on. - Keep the 6 deleted twins one more release. They already coexist with their semconv replacements today, so keeping them costs nothing new.
- Emit a startup WARN naming each deprecated metric and the release it will be removed in.
- Document the deprecation — an explicit old → new table plus a stated removal release. The mapping table in this PR is already most of the way there; it needs to be framed as a deprecation schedule rather than a one-shot cutover.
- Remove both in a later minor, and call it out prominently in the release notes.
Why the renames are worse than the deletions
I verified the counts against the mapping table at a8c3103b: 21 legacy toolhive_* metric names — 6 deleted, 15 renamed — plus 3 counters merged into outcome labels. After this PR, toolhive_ appears in zero metric names in non-test Go source.
The intuition here inverts, and I think it's the crux of the disagreement:
- The 6 deletions are the least disruptive category. Their semconv replacements (
mcp.server.operation.duration,mcp.client.operation.duration) already exist and are already emitted today, alongside the twins. A user can migrate their queries before upgrading, run both, confirm they agree, then upgrade. That overlap window is exactly what RFC D13's argument rests on. - The 15 renames + 3 merges have no overlap window at all. The old name stops and the new name starts in the same release. There is no moment where a user can run both and compare.
So D13 justifies the category that was already safe, and doesn't reach the category that isn't. A rename empties a dashboard panel exactly the same way a deletion does.
It also compounds: on the rate-limit metrics both the metric and its label change (toolhive_rate_limit_decisions{server=…} → stacklok_toolhive_ratelimit_decisions_total{mcp_server=…}), so those dashboards break twice over.
Three breakages not currently in the guide
- Histogram bucket boundaries change on metrics whose names survive.
mcp.server.operation.durationmoves fromBucketsMCPSemconv()[…0.02, 0.2, 2…]toBucketsMCPProxy()[…0.25, 2.5…]. Anyhistogram_quantile()spanning the upgrade mixes two incompatible layouts and returns silently wrong numbers. This is also the unresolved #6144 conflict — see the inline comment. telemetry.MCPHistogramBucketsis deleted from a public package. Exported identifier ingithub.com/stacklok/toolhive/pkg/telemetry, currently referenced from six places in-repo. External importers break at compile time, not just at query time. Not mentioned anywhere in the guide.stacklok.build_infois documented under a name Prometheus never emits. It exports asstacklok_build_info_ratio(theWithUnit("1")→_ratiorule). Reproduced against this repo's pinned deps. So even a user who does the migration work lands on a query matching nothing.
And docs/telemetry-migration-guide.md:213 still tells operators "Your existing dashboards and alerts continue to work without changes" — which contradicts the Backward Compatibility table two sections above it.
One note on risk assessment: green CI isn't much signal here. The repo ships no PrometheusRule or alert artifacts, so nothing in-repo can catch a bad metrics migration. The first signal is a customer's dashboard going blank.
Review findings
Reviewed at a8c3103b across six specialist passes (OTel semconv, Go concurrency/lifecycle, backwards-compat/docs, test coverage, security/config, architecture). Claims marked verified were reproduced against this repo's pinned dependencies.
Already fixed in 46864d8e — re-verified, no action needed: mcp_server restored on both proxy request metrics; OwnershipLabels switched to literal Prometheus label names; SSE split into its own stacklok.toolhive.proxy.sse_connection.duration on BucketsMCPProxy(). Good fixes.
Consensus summary
| Severity | Finding | Location |
|---|---|---|
| HIGH | http.request.method recorded raw — unbounded attacker-controlled cardinality; semconv _OTHER normalization missing |
middleware.go:747 |
| HIGH | Semconv histogram buckets switched to the coarser proxy preset — reverts #6144, breaks quantile continuity | middleware.go:86 |
| HIGH | stacklok.build_info exports as stacklok_build_info_ratio; test passes on substring |
buildinfo_test.go:41 |
| HIGH | Step 1 still promises existing dashboards keep working | telemetry-migration-guide.md:213 |
| HIGH | Documented cardinality bound doesn't exist — gen_ai.tool.name gets the same unbounded value |
observability.md:231 |
| MEDIUM | build_info registered per-middleware, not once per process, with no unregister — doc overclaims |
middleware.go:127 |
| MEDIUM | mcp.method.name is an unvalidated JSON-RPC method string from the request body |
middleware.go:707 |
| MEDIUM | backendHealth.states never pruned — unbounded map keyed by workload ID |
backendtelemetry.go:242 |
| MEDIUM | Health gauge flips to unhealthy on context.Canceled and auth failures |
backendtelemetry.go:373 |
| MEDIUM | HealthProviderSetter's two-phase init rests on a premise that isn't true |
backendtelemetry.go:203 |
| MEDIUM | Unregister cleanup manually replicated across 5 error paths | core_vmcp.go:163 |
| MEDIUM | Optimizer tool_name is unvalidated client input, now multiplied by outcome |
factory.go:418 |
| MEDIUM | Test: "health monitor build fails" case is vacuous; doc comment claims a 5th branch with no case | core_vmcp_test.go:226 |
| MEDIUM | Test: no dp.Count assertion (double-record passes) and no 4xx case for the documented narrowing |
middleware_test.go:2555 |
| MEDIUM | Test: tautological guard gates the positive assertions | integration_test.go:181 |
| MEDIUM | Test: e2e helper passes silently on zero regex matches | telemetry_metrics_validation_e2e_test.go:360 |
| MEDIUM | Test: validateNoEmptyLabels has no zero-match guard after narrowing to one metric |
telemetry_metrics_validation_e2e_test.go:334 |
| MEDIUM | Test: dropped server-identity assertions without replacement | telemetry_integration_test.go:338 |
| MEDIUM | "What Is New" table missing the three genuinely new metrics | telemetry-migration-guide.md:29 |
Not anchorable inline (outside the diff)
examples/otel/grafana-dashboards/toolhive-mcp-otel-semconv-dashboard.json:246and:974— the semconv dashboard's own Error Rate panels still useerror_type!="", which is exactly the 4xx trap the migration guide warns about. The other three dashboards were correctly moved tohttp_response_status_code=~"[45].."; these two weren't, so the shipped dashboards now disagree on what "error rate" means and this one silently under-reports 403s and 429s.docs/arch/10-virtual-mcp-architecture.md:885— still references the removedmcp_methodlabel. CLAUDE.md requiresdocs/arch/to be updated when architecture changes..claude/agents/site-reliability-engineer.md:23— still points at the deletedpkg/vmcp/server/telemetry.go.- Three rewritten dashboards lack
sum by (...)—rate(mcp_server_operation_duration_seconds_count{…})withlegendFormat: "{{mcp_method_name}}"and no aggregation fanstools/callout to one series per tool, all rendered with the same legend string. The semconv dashboard does this correctly (sum by (le, mcp_method_name)), so the four are now inconsistent. docs/operator/virtualmcpserver-observability.md— the migration guide now points readers here for the optimizer andrevision_reclassificationsmetrics, but this doc documents none of them.
Worth noting on the positive side
The callback lifecycle work holds up under scrutiny: every one of the five post-registration error paths in core.New unregisters, Close() is idempotent (the SDK's Unregister is safe twice), the observable callback is genuinely non-blocking (registry.List is an RLock map copy), and the shared state it touches is race-free. The rename itself is also complete — repo-wide greps turn up no missed producer or consumer in deploy/, charts, ServiceMonitors, or alert rules. And replacing nil instruments with noop.* fallbacks quietly fixes a pre-existing panic path.
Scope
At 751 lines of production change across 9 files this is ~88% over the 400-line budget in CLAUDE.md, and three genuine behaviour changes (the health gauge, http.server.request.duration, the D8 hardening) are interleaved with a rename-dominated diff. Given the PR is already 20+ commits deep I don't think splitting now is worth it — but the backend-health gauge is the piece that would most have benefited from separate review.
🤖 Generated with Claude Code
| ctx context.Context, method string, statusCode int, duration time.Duration, | ||
| ) { | ||
| specAttrs := []attribute.KeyValue{ | ||
| attribute.String("http.request.method", method), |
There was a problem hiding this comment.
HIGH — http.request.method is recorded raw: unbounded, attacker-controlled cardinality.
Go's net/http accepts any RFC 7230 token as a method, and /metrics plus the MCP port are unauthenticated by default here. curl -X ZZZZ1, -X ZZZZ2, … mints a permanently-retained series per distinct method — each multiplied across the bucket set — in both the SDK's in-memory aggregation and Prometheus.
OTel HTTP semconv mandates exactly the missing mitigation: methods outside the known set MUST be recorded as _OTHER, with the original kept on spans only (http.request.method_original, which should not go on the metric).
The deleted toolhive_mcp_requests carried the same raw method, so this isn't net-new — but it's reintroduced on a metric this PR presents as semconv-compliant, and it's now the one metric recorded on every request path.
Separately, semconv lists url.scheme as Required on http.server.request.duration, and network.protocol.version / server.address as recommended. httpProtocolVersion(r) and mapTransport() are already computed a few lines down for mcp.server.operation.duration, so the data is at hand. http.route should be added only from a bounded route pattern — never the raw r.URL.Path.
There was a problem hiding this comment.
Addressed in 09b576fdf.
normalizeHTTPMethod maps anything outside the nine RFC 9110 methods to _OTHER. Verified by hand: five curl -X bogus-N requests collapse to a single _OTHER series.
On http.request.method_original — I didn't add it, and I've corrected the comment that claimed I had (3f960e6b8). The span keeps the raw value under the spec name http.request.method; moving it to _original belongs with the rest of the span-attribute work (RFC D9), so I left the metric bounded and the span as-is rather than half-applying the spec.
Also added the semconv-Required url.scheme and recommended network.protocol.version while here.
| metric.WithDescription("Duration of MCP server operations"), | ||
| metric.WithUnit("s"), | ||
| metric.WithExplicitBucketBoundaries(MCPHistogramBuckets...), | ||
| metric.WithExplicitBucketBoundaries(coremetrics.BucketsMCPProxy()...), |
There was a problem hiding this comment.
HIGH — this reverts #6144, and changes bucket edges on an already-shipped metric.
247480f16 ("Adopt core MCP semconv bucket preset in telemetry", #6144) landed on main and deliberately put this exact line on BucketsMCPSemconv(). This branch moves it to BucketsMCPProxy(). @aponcedeleonch flagged this as the live rebase conflict and it's still unresolved at a8c3103b.
toolhive-core's own doc comment says BucketsMCPSemconv exists "for emitters that promise semconv fidelity" and names mcp.server.operation.duration as the example — so a semconv-named metric should carry semconv buckets.
Beyond the revert, the edges themselves change: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, …] → [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, …]. Any histogram_quantile() over a range spanning the upgrade mixes two incompatible layouts and returns silently wrong values. The migration guide documents zero bucket changes.
pkg/ratelimit/observability.go:80 and pkg/vmcp/core/core_telemetry.go:57 make the same substitution without conflicting textually and want the same look. On the latter: toolhive-core documents BucketsLongRunning() as being "for long-running measurements such as sync, reconcile, or composite-tool durations" — naming that exact use case.
There was a problem hiding this comment.
Addressed in 09b576fdf. mcp.server.operation.duration and mcp.client.operation.duration are back on BucketsMCPSemconv(); #6144 stands.
The root cause was a single package-level MCPHistogramBuckets var serving three measurement classes, so one edit moved buckets on unrelated metrics. That var is gone and each call site now names its preset.
Two histograms do change edges deliberately: rate-limit latency → BucketsMCPProxy() and composite-tool duration → BucketsLongRunning() (which toolhive-core documents for exactly that case). Called out rather than left silent.
Your point generalised further than the three lines you flagged: the dual-emit aliases on #6168 had the same problem — six re-emitted their original name on BucketsMCPProxy() when those names shipped on semconv. Fixed there in 4565242c8, with a test pinning the boundaries.
| handler.ServeHTTP(rw, req) | ||
| body := rw.Body.String() | ||
|
|
||
| require.Contains(t, body, "stacklok_build_info", "build_info metric should be exported") |
There was a problem hiding this comment.
HIGH — this assertion cannot fail for the property it claims to check, and the documented metric name doesn't exist.
coremetrics.RegisterBuildInfo sets metric.WithUnit("1"), and the Prometheus translator appends _ratio to any gauge with unit "1". I reproduced this against this repo's pinned deps:
stacklok_build_info_ratio{commit="abc123",component="toolhive",version="v1.2.3"} 1
require.Contains(t, body, "stacklok_build_info") is satisfied by stacklok_build_info_ratio as a substring, so the test passes while docs/observability.md:271 documents a name that matches nothing. Any fleet dashboard joining on stacklok_build_info returns empty.
Anchor the assertion at line start (^stacklok_build_info(_ratio)?\{) so the exported name is pinned, and reconcile with the docs. The durable fix is upstream — drop WithUnit("1") for a unitless identity gauge — worth filing before this vocabulary reaches customer dashboards.
Also worth asserting the labels: build_info always observes 1, so component="toolhive" / non-empty version / non-empty commit are the entire point of the metric, and none of them is currently checked.
There was a problem hiding this comment.
Addressed in 13d69bc4b, 6f3c6c882, and 3f960e6b8. You were right on both halves, and the second was worse than the comment suggests.
The assertion is now anchored at line start ((?m)^stacklok_build_info(_ratio)?\{) and asserts all three labels, since the value is always 1.
On the name: confirmed stacklok_build_info_ratio against a real scrape. docs/observability.md now documents the exported name and flags _ratio as wrong for an identity gauge — the migration guide already had this right, observability.md didn't.
The actual fix can't happen in this repo: RegisterBuildInfo and its WithUnit("1") live in toolhive-core, so it needs a core change plus a pin bump here. Filed as #6170. Until that lands the (_ratio)? in the test regex and the docs note stay; both come out with the pin bump.
Separately, chasing this surfaced a real bug in my own once-guard — see reply on 3685784106.
| @@ -178,15 +212,17 @@ When upgrading to this release, dual emission is enabled by default. Both old | |||
| and new attribute names appear on spans. Your existing dashboards and alerts | |||
| continue to work without changes. | |||
There was a problem hiding this comment.
HIGH — this is now false, and it's the first thing an operator reads when deciding whether an upgrade is safe.
Six metrics are deleted, 15 renamed, 3 merged, and several label keys changed. The Backward Compatibility table two sections above explicitly states metrics are a hard cutover, no legacy fallback — this paragraph directly contradicts it.
The dual-emission reassurance is true only for span attributes. Suggest retitling to scope it explicitly, e.g. "Step 1: Upgrade — Span Attributes Need No Action", and stating plainly that metric-based dashboards and alerts do break, with a pointer to Step 2.
Related: Step 4 ("Update Dashboard Panels") still advises running old and new queries "side-by-side during migration to verify equivalence" — impossible for metrics with no dual emission. Worth rescoping to trace-based panels.
There was a problem hiding this comment.
Fixed by #6168 rather than by an edit here.
That PR is open and stacked on this branch: --otel-use-legacy-metrics defaults to true and emits all 25 pre-rename names under their original label keys. Once it lands, "Step 1: No Action Required" is true for metrics as well as spans — the state this section already describes.
The guide is reworked there too: the metric half becomes a deprecation schedule rather than a cutover, and the Backward Compatibility table you cite two sections above no longer says "hard cutover, no legacy fallback".
Being explicit about the dependency: if #6168 doesn't land, this section is wrong as written and needs the retitle you suggested plus a plain statement that metric dashboards break. I've left it pending that PR rather than writing it both ways.
|
|
||
| #### `toolhive_mcp_tool_calls` (Counter) | ||
| > **Note**: `mcp_resource_id` (tool name, resource URI, or prompt name) is | ||
| > deliberately omitted from this metric's attributes to bound cardinality; it |
There was a problem hiding this comment.
HIGH — this cardinality claim doesn't hold, and it will mislead operators into thinking the vector is closed.
recordOperationDuration (middleware.go:791) sets gen_ai.tool.name to resourceID — the same parsedMCP.ResourceID value, i.e. params.name straight from the client's JSON-RPC body. For tools/call and prompts/get the unbounded value simply moved to a different key on the same metric; only resources/read (the URI) genuinely lost it.
Since mcp.server.operation.duration is now the sole per-method metric after the twins were deleted, arbitrary params.name values grow the series set without bound.
Either keep gen_ai.* names on spans only, or bound them against the aggregated capability set the proxy already knows (emitting _OTHER for unresolved names). Either way the note needs correcting — as written it asserts a control that isn't there.
There was a problem hiding this comment.
You are right that the note asserts a control that is not there, and that the value simply moved keys rather than going away. The doc fix is coming in the docs commit.
On the code fix, I dug into both branches you offered and neither lands cleanly, so I want to flag what I found rather than pick quietly.
"bound them against the aggregated capability set the proxy already knows" — the proxy does not know it. Checked both paths:
- Plain proxy: nothing in the runner/transport path retains a
tools/listresult.telemetry.CreateMiddleware's only window onto wider state istypes.RunnerConfig, which isGetName()+GetPort(). ReachingRunConfig.ToolsFilterneeds a type assertion, which.claude/rules/go-style.mdcalls out explicitly. --toolsis user-supplied and skipped entirely when empty (pkg/runner/middleware.go:129), so it bounds nothing for most workloads.ToolsOverridealso means the wire name is not the backend name.- vMCP has a real set (
RoutingTable.Tools), but only behindcore.ListTools(ctx, identity)— async, per-identity, 30s TTL, invalidated bylist_changed. Not snapshottable at construction, and using the authorized view would leak whether a caller is authorized for a tool into a metric label.
Worth noting: backendtelemetry.go:396 already keeps gen_ai.tool.name span-only for the outbound leg — its metric attrs are just mcp.method.name / network.transport / mcp_server. So your branch 1 is what the codebase already converged on, and the two surfaces are currently inconsistent.
What I did: left the attributes as-is here. They predate this branch (they were on recordOperationDuration at the merge-base), so removing them drops a working per-tool metric breakdown that is not in the guide's breaking-change list — a user-visible removal that deserves its own review rather than riding along in a rename. Filing a follow-up for the bounding, where the span-only-vs-distinct-value-cap tradeoff can be decided on its own.
Your point that the deletions make this the sole per-method metric is well taken and is the reason it needs fixing at all — just not, I think, in this PR.
There was a problem hiding this comment.
Addressed in 4860adcbc — as a corrected claim, not a fix.
You're right that the unbounded value moved keys rather than going away: resourceID is the same params.name, so tools/call and prompts/get still carry it, and only resources/read genuinely lost it. The doc now says so plainly, including that this is the sole per-method metric once the twins are retired, so there's no unaffected fallback.
Not fixed here: bounding it needs the resolved tool/prompt set, which the middleware has no access to — it sees a parsed request, not the backend's capability list. That's a design change, not a guard. Filed as #6169, covering this and the vMCP tool_name case from your other comment.
Interim mitigation is documented: drop the attributes with a Prometheus metric_relabel_config if untrusted clients can reach the proxy.
| // The renamed active-connections gauge and the semconv HTTP duration must | ||
| // be present (both are recorded for every handled request). | ||
| if strings.Contains(metricsBody, "stacklok_toolhive_proxy") { | ||
| assert.Contains(t, metricsBody, metricActiveConnections) |
There was a problem hiding this comment.
MEDIUM — this guard is a prefix of the string it guards, so the assertion is tautological.
metricActiveConnections == "stacklok_toolhive_proxy_active_connections", and the guard is strings.Contains(metricsBody, "stacklok_toolhive_proxy"). The first assertion can therefore never fail, and the second only runs when the gauge already happens to be present. If the middleware emitted nothing at all, the whole block is skipped and the test still passes.
The test already made a real request through the middleware, so both series must exist — dropping the if and asserting unconditionally is strictly stronger.
(The NotContains assertions above this are unconditional and genuinely valuable — this is only about the guarded positive half.)
There was a problem hiding this comment.
Addressed in 70302f0a5. Guard removed, assertions now unconditional.
Removing it exposed exactly what you'd expect it to be hiding: the request-driving subtests ran under t.Parallel(), so they hadn't executed when the parent scraped /metrics — the metric was genuinely absent from the scrape the test claimed to validate. Requests are now served inline before the scrape.
| @@ -350,12 +358,13 @@ func validateNoEmptyLabels(metricsContent, expectedServerName, expectedTransport | |||
|
|
|||
| // validateMetricValues validates that metric values are reasonable | |||
| func validateMetricValues(metricsContent, expectedServerName, expectedTransport string) { | |||
There was a problem hiding this comment.
MEDIUM — the regex was weakened and the helper still can't fail.
The pattern went from toolhive_mcp_requests_total\{.*server="X".*transport="Y".*\} (\d+) to mcp_server_operation_duration_seconds_count\{[^}]*\} (\d+), dropping both the server-name and transport pinning. The surrounding if len(matches) > 0 { … } guard is left in place, so if the regex matches nothing the entire body — including Expect(totalRequests).To(BeNumerically(">", 0)) — is skipped and the helper passes silently.
Expect(matches).ToNot(BeEmpty(), "expected ... series in scrape") makes it capable of failing. Now that 46864d8e has restored mcp_server on this metric, the server-identity pinning can also come back.
There was a problem hiding this comment.
Addressed in 70302f0a5. Expect(matches).ToNot(BeEmpty()) replaces the if len(matches) > 0 guard, and the server-name pinning is back now that 46864d8ee restored mcp_server on that metric.
|
|
||
| for _, line := range lines { | ||
| if strings.Contains(line, "toolhive_mcp") && !strings.HasPrefix(line, "#") { | ||
| // The active-connections gauge is the series that carries both mcp_server |
There was a problem hiding this comment.
MEDIUM — no zero-match guard after narrowing the scan from many series to one.
This previously scanned every line containing toolhive_mcp; it now scans only stacklok_toolhive_proxy_active_connections. If that single gauge is missing from the scrape for any reason — instrument creation fell back to the new noop.Int64UpDownCounter{}, a rename, an exporter change — the loop body never executes and the helper passes without asserting anything.
Unlike analyzeTraceAttributes, whose caller asserts TracesGenerated > 0 downstream, there's no backstop here. A checkedLines counter plus Expect(checkedLines).To(BeNumerically(">", 0)) closes it.
There was a problem hiding this comment.
Addressed in 70302f0a5 — checkedLines counter with Expect(checkedLines).To(BeNumerically(">", 0)).
| // Request duration histogram | ||
| assert.Contains(t, metrics, "toolhive_mcp_request_duration", | ||
| "Should record request duration histogram") | ||
| // --- Deleted legacy twins must be absent (single-pass cutover) --- |
There was a problem hiding this comment.
MEDIUM — server-identity assertions were dropped without replacement, in the one test that scrapes a real vMCP /metrics.
The rewrite deleted assert.Contains(metrics, 'server="telemetry-vmcp"'), 'transport="streamable-http"', and 'mcp_resource_id="search-svc_search"', replacing them only with mcp_method_name= checks. So the central server → mcp_server rename this PR performs isn't verified end-to-end anywhere. Same in TestIntegration_TelemetryRunsBeforeClassificationRejection, where server="telemetry-ordering-vmcp" gave way to a status-code check.
mcp_server="telemetry-vmcp" now lands on stacklok_toolhive_proxy_active_connections and mcp_client_operation_duration_seconds, so asserting it is cheap. The transport= assertion is unaffected by this PR and could simply come back.
There was a problem hiding this comment.
Addressed in 70302f0a5. mcp_server and transport assertions restored in both tests.
One detail worth recording: the negative form needs a delimiter anchor. NotContains("server=\"x\"") is satisfied by mcp_server="x" as a substring, so it can't fail — it's now NotRegexp(`[{,]server="x"`). Same trap as the guard in 3685784177, different shape.
| 3. **Metric name and label standardization** — The legacy `toolhive_mcp_*` and | ||
| `toolhive_vmcp_*` metric names and their label vocabulary (`server`, | ||
| `mcp_method`, `tool`, `workflow.name`, …) have been replaced by the shared | ||
| `stacklok.*`/OTel-semconv vocabulary (`mcp_server`, `mcp_method_name`, |
There was a problem hiding this comment.
MEDIUM — the "What Is New" table contradicts the "What Changed" section above it.
"What Changed" was updated to describe three new histograms, but this table still lists only mcp.server.operation.duration and mcp.client.operation.duration. Missing:
http.server.request.durationstacklok.toolhive.proxy.sse_connection.duration(added in46864d8e)stacklok.build_infostacklok.vmcp.mcp_server.health— semantically new, not a rename oftoolhive_vmcp_backends_discovered- the new
outcomelabel vocabulary
Worth adding here since this table is what a reader scans to find out what they gain in exchange for the queries they have to rewrite.
There was a problem hiding this comment.
Addressed in 6cb43cb76.
stacklok.vmcp.mcp_server.health is listed as semantically new rather than a rename of backends_discovered — worth the distinction, since the old one was a startup constant and the new one is a live gauge.
Semconv-named metrics were switched to the coarser Stacklok proxy bucket preset, reverting #6144 and changing bucket edges on already-shipped metrics. Any histogram_quantile() spanning the upgrade mixes two incompatible layouts and returns silently wrong values. Metric attributes taken verbatim from client input are also unbounded: Go's net/http accepts any RFC 7230 token as a method, and the JSON-RPC method comes straight from the request body, so an unauthenticated caller can mint a permanently-retained series per distinct string. Addresses #5956 review comments: - HIGH pkg/telemetry/middleware.go:86 (3685784078): restore BucketsMCPSemconv on mcp.server.operation.duration and mcp.client.operation.duration; move composite_tool.duration to BucketsLongRunning, the preset core documents for that use case - HIGH pkg/telemetry/middleware.go:747 (3685784071): normalize http.request.method to the semconv _OTHER sentinel outside the known set, and add the Required url.scheme plus network.protocol.version - MEDIUM pkg/telemetry/middleware.go:707 (3685784113): bound mcp.method.name against the parser's own method set via mcpparser.IsKnownMethod, recording _OTHER otherwise - MEDIUM pkg/telemetry/middleware_test.go:2555 (3685784174): assert dp.Count on both request histograms so double-recording is detectable, and add the missing 4xx case pinning that error.type is set only for status >= 500 gen_ai.tool.name and gen_ai.prompt.name remain unbounded on the metric: they predate this branch, and the proxy holds no resolved capability set to validate them against. Tracked separately.
The health gauge reported client-caused failures as backend unhealthiness: any non-nil error set BackendUnhealthy, including context.Canceled from a caller that disconnected and auth failures that have their own state in the gauge's vocabulary. A single cancelled request pinned the gauge — and any alert reading it — until the next successful call to that backend. The recorded-health map was also write-only: set() added an entry per distinct WorkloadID and nothing removed one, so under dynamic discovery it grew for the process lifetime over an ID space the process does not control, with snapshot() copying all of it every collection. Addresses #5956 review comments: - MEDIUM backendtelemetry.go:373 (3685784128): classify errors via healthStatusForError — skip the health update for context cancellation/deadline, map auth failures to BackendUnauthenticated to match health.categorizeError's vocabulary - MEDIUM backendtelemetry.go:242 (3685784119): prune states against the live set from registry.List, which the gauge callback already holds - MEDIUM core_vmcp.go:163 (3685784145): replace cleanup replicated across five error paths with one deferred cleanup keyed on success, so future error paths are correct by construction; nil-guard unregisterHealthLogged and Close's stopStore so an in-package struct literal is safe to close - MEDIUM backendtelemetry.go:203 (3685784135): correct HealthProviderSetter's doc — buildHealthMonitor uses the undecorated client, so the ordering is a resource-acquisition choice, not a data dependency - MEDIUM core_vmcp_test.go:226 (3685784163): the "health monitor build fails" case returned an empty backend list, so the gauge emitted zero series whether or not the callback was unregistered; return a non-empty list and add a positive control that also covers the Close() path
Several assertions guarding the rename could not fail. Removing the guard in integration_test.go exposed a real defect it was masking: the request subtests ran with t.Parallel(), so they had not executed when the parent scraped /metrics — http.server.request.duration was genuinely absent from the scrape the test claimed to validate. Addresses #5956 review comments: - MEDIUM integration_test.go:181 (3685784177): the guard strings.Contains(body, "stacklok_toolhive_proxy") is a prefix of metricActiveConnections, so the assertion it gated was tautological; assert unconditionally and serve the requests inline so the metrics exist by scrape time - MEDIUM telemetry_metrics_validation_e2e_test.go:360 (3685784184): restore mcp_server pinning to the regex (46864d8 put the label back) and assert matches is non-empty, so a zero-match scrape fails instead of skipping the count assertion - MEDIUM telemetry_metrics_validation_e2e_test.go:334 (3685784195): add a checkedLines counter so narrowing the scan to a single gauge cannot pass without asserting anything - MEDIUM telemetry_integration_test.go:338 (3685784205): restore the dropped server-identity assertions. mcp_server="telemetry-vmcp" and transport="streamable-http" are asserted, and the pre-rename check is anchored as [{,]server=" — a plain NotContains is satisfied by mcp_server=" as a substring and so could never fail.
build_info is process identity, not an HTTP-middleware concern, and
RegisterBuildInfo attaches an observable callback while returning no handle
to release it. NewHTTPMiddleware is reachable from the public
Provider.Middleware() with no arity constraint, so a process instrumenting
two proxies — or rebuilding its middleware chain — permanently attached
duplicate callbacks observing the same attribute set, contradicting the
"registered once per process" claim in docs/observability.md.
The gauge also exports as stacklok_build_info_ratio: RegisterBuildInfo sets
WithUnit("1") and the Prometheus translator appends _ratio to any gauge with
that unit. A Contains("stacklok_build_info") assertion is satisfied by that
name as a substring, so the test could not detect the discrepancy.
Addresses #5956 review comments:
- MEDIUM pkg/telemetry/middleware.go:127 (3685784106): guard registration
with sync.Once, making the doc claim true by construction, and add a
regression test asserting at most one series. Registering during provider
assembly remains the durable fix.
- HIGH pkg/telemetry/buildinfo_test.go:41 (3685784085): anchor the
assertion at line start so the exported name is pinned, and assert the
component/version/commit labels — build_info always observes 1, so its
labels are the entire point of the metric.
The four shipped dashboards disagreed on what "error rate" means. Three were
moved to http_response_status_code=~"[45].." but the semconv dashboard's two
Error Rate panels still used error_type!="", which after the narrowing to
status >= 500 silently under-reports 403s and 429s — the exact trap the
migration guide warns about.
Three dashboards also compute per-method rates with legendFormat
{{mcp_method_name}} but no aggregation, so tools/call fans out to one series
per tool, all rendered under the same legend string. The semconv dashboard
already did this correctly.
Addresses #5956 review findings (not anchorable inline):
- semconv dashboard :246 and :974 — switch both Error Rate panels to
http_server_request_duration_seconds_count with a [45].. status matcher, so
all four dashboards agree
- three dashboards, 9 panels — add sum by (mcp_method_name) to counters and
sum by (le, mcp_method_name) inside histogram_quantile
- docs/arch/10-virtual-mcp-architecture.md:885 — mcp_method is now
mcp_method_name
- .claude/agents/site-reliability-engineer.md:23 — pkg/vmcp/server/telemetry.go
was deleted; point at backendtelemetry/ and core_telemetry.go
- docs/operator/virtualmcpserver-observability.md — document the optimizer
find_tool/call_tool metrics and revision_reclassifications, which the
migration guide now points here for; also correct composite_tool.duration's
bucket preset to BucketsLongRunning
observability.md asserted a cardinality control that does not exist: the note said mcp_resource_id was omitted to bound cardinality, but for tools/call and prompts/get the same client-supplied params.name value is recorded as gen_ai.tool.name/gen_ai.prompt.name on the same metric. Only resources/read genuinely lost it. An operator reading the note would believe the vector is closed when it is not. The migration guide's "What Is New" table also contradicted the "What Changed" section above it, listing two new histograms where three shipped. Addresses #5956 review comments: - HIGH docs/observability.md:231 (3685784101): state plainly that gen_ai.tool.name/prompt.name are unvalidated client input with no bound, note there is no unaffected alternative now that the twins are deleted, and give operators the metric_relabel_config escape hatch. Also record that mcp.method.name and http.request.method ARE bounded via _OTHER. - MEDIUM docs/telemetry-migration-guide.md:29 (3685784212): add the missing additions — http.server.request.duration, the SSE connection histogram, stacklok.build_info (with its _ratio exported name), stacklok.vmcp.mcp_server.health, and the outcome label vocabulary.
healthStatusForError matched context.Canceled/DeadlineExceeded, but client.wrapBackendError formats the origin error with %v and wraps only the domain sentinel with %w, so the context sentinels are never in the chain by the time an error reaches the telemetry decorator. The guard compiled and read correctly while never firing: every cancelled or timed-out call still set BackendUnhealthy, pinning the gauge -- and any alert on it -- until the next successful call to that backend. That is the defect the guard was written to prevent. Match vmcp.ErrCancelled/ErrTimeout, which the client does wrap with %w, and keep the context sentinels for callers that hand us a raw error. health.categorizeError checks the same domain sentinels. The table gained the error shape wrapBackendError actually produces; asserting on a bare context.Canceled cannot distinguish the two.
A process-wide sync.Once bound registration to the first meter, so every MeterProvider after the first exported no build_info at all. Each provider owns its own reader and /metrics endpoint, and NewHTTPMiddleware is reachable both from Provider.Middleware() and from CreateMiddleware, which builds a fresh provider per call -- so a process instrumenting more than one surface silently lost the gauge on every scrape target but one. Absence is the harder failure to notice: a fleet dashboard joining on build_info just returns empty for that target, with no error and no series. Track registered providers instead. That still collapses the duplicate callbacks the once-guard was added for, since RegisterBuildInfo returns no handle to release, while keeping the gauge present per endpoint. The old assertion (LessOrEqual(len(series), 1)) was satisfied by zero, so it passed both when the gauge was deduped and when it was missing entirely. Require exactly one, and add a case pinning that a second provider exports it too.
Three bucket tables disagreed with the code, and an operator sizing a recording rule or SLO alert reads le values straight off them. A rule on le="0.25" against mcp.server.operation.duration matches no series, so it evaluates to no data and the alert never fires. The semconv-named operation-duration metrics were documented as BucketsMCPProxy but moved to BucketsMCPSemconv when the semconv preset was restored; composite_tool.duration listed a boundary set with a spurious 0.25 and no 180, which reads plausibly because the count still matches. Values now match toolhive-core v0.0.37 exactly.
observability.md documented the metric as stacklok.build_info, but RegisterBuildInfo sets the OTel unit to 1 and the Prometheus translator appends a suffix to a dimensionless gauge, so it scrapes as stacklok_build_info_ratio. A dashboard joining on the documented name returned empty. The migration guide already said this; observability.md did not. Also corrects normalizeHTTPMethod's comment, which claimed the raw method is kept on the span as http.request.method_original. Nothing sets that attribute -- the span carries the raw value under the spec name http.request.method. The _original rename belongs with the rest of the span-attribute work (RFC D9), so say what the code does instead of what semconv would prefer. Both are claims a reader would rely on and neither was true.
Two comments went out of date with the changes above them: recordedHealth's says the map is "never seeded or pruned here", but the gauge callback now prunes it to the live registry set via retain(). Both it and backendHealth's say set() can only ever receive BackendHealthy or BackendUnhealthy, and list unauthenticated among the states only the registry can supply. healthStatusForError classifies auth failures as BackendUnauthenticated, so record() sets that too; only degraded and unknown are registry-only now. A comment that contradicts the code is worse than none -- these describe which states reach the gauge from where, which is the first thing a reader checks when a health series looks wrong.
|
Thanks Chris — this was a genuinely useful review. Two of your comments found bugs whose first fix was wrong in a way I'd have shipped:
Your bucket comment also generalised further than the lines you flagged — six dual-emit aliases on #6168 had the same problem under unchanged metric names. Fixed there. And fixing two of your comments left a third comment stale, which Three things resolved outside this PR, with reasons in the individual replies:
One design decision I've made rather than deferred: 3685784135 — keeping Everything else in your review is addressed on the branch. |
Summary
The proxy and vMCP used
toolhive_mcp_*/toolhive_vmcp_*metric names, emitted six legacy metrics alongside their emerging-semconv replacements, and diverged from the platform label vocabulary. This finishes the semconv migration and adopts the sharedtoolhive-corevocabulary, per the Metrics Standardization RFC.pkg/telemetry/):toolhive_mcp_active_connections→stacklok.toolhive.proxy.active_connections;build_infoviacoremetrics.RegisterBuildInfo; addedhttp.server.request.duration(OTel HTTP semconv) so deleting the legacy MCP twins doesn't drop transport-level coverage; D8 ownership labels (stacklok_component=toolhive,stacklok_product=stacklok-platform) promoted to every series viaWithResourceAsConstantLabelsand hardened as the final OTEL resource detector so a CLI custom attribute orOTEL_RESOURCE_ATTRIBUTESentry for those keys cannot override the frozen values.pkg/vmcp/):toolhive_vmcp_workflow_*→stacklok.vmcp.composite_tool.*; backendmcp_server.healthgauge rename to a live per-(mcp_server, state)gauge, keyed by workload ID for consistency with the backend registry and health-status provider; optimizer renames +token_savings.toolhive_mcp_requests,toolhive_mcp_request_duration,toolhive_mcp_tool_calls,toolhive_vmcp_backend_requests,toolhive_vmcp_backend_errors,toolhive_vmcp_backend_requests_duration). Their semconv replacements (mcp.server.operation.duration,mcp.client.operation.duration,http.server.request.duration) are kept.toolhive-coredependency tov0.0.34, the release carryingtelemetry/metrics(adopts core label keys, outcome values, and bucket presets). The transitive AWS-SDK and grpc bumps ingo.modcome fromtoolhive-core's own requirements.test/integration/vmcp/,test/e2e/) that asserted the deleted/renamed metric names and old label scheme (mcp_method→mcp_method_name,server→mcp_server,status→HTTP status code) to assert the semconv replacements.docs/telemetry-migration-guide.mdfor the renamed/deleted metrics, including a complete old→new metric and label mapping and a correctedMCP Request Ratevs. total HTTP request-rate query split.examples/otel/prometheus-stack-values.yaml) so the updated example dashboards can be auto-provisioned into a local Kind cluster via a labeled ConfigMap, for validating the renamed metrics land correctly end-to-end.pkg/ratelimit/observability.go, missed by the original pass:toolhive_rate_limit_decisions/_redis_errors/_check_latency→stacklok.toolhive.ratelimit.{decisions,redis_errors,check_latency}, with the migration guide anddocs/observability.mdupdated to match.toolhive_vmcp_backend_revision_reclassifications(added independently onmainafter this branch forked) tostacklok.vmcp.backend.revision_reclassificationsso it doesn't reintroduce the legacy naming scheme this PR retires.docs/telemetry-migration-guide.md's Backward Compatibility section: span attributes and metrics follow different policies (dual-emission vs. hard cutover) for different reasons — the guide previously implied one uniform strategy.Type of change
Test plan
task test)task lint-fix)Verified the shipped standalone path (
GOWORK=off, publishedtoolhive-core v0.0.34, no workspace):GOWORK=off go build ./...— clean.GOWORK=off go test ./pkg/telemetry/... ./pkg/vmcp/...— all packages pass, including the D8 label-promotion assertion inpkg/telemetry/buildinfo_test.go(stacklok_component="toolhive"/stacklok_product="stacklok-platform"promoted to per-series labels) and the updatedTestVMCPServer_Telemetry_CompositeToolMetricsintegration test.task test(full suite) is green.test/e2e/) were updated for the new metric/label vocabulary and compile clean (go vet); they require a fulltask test-e2erun (containers) to exercise end-to-end.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
Yes. Prometheus metric names emitted by the proxy, vMCP, and rate limiting change to the
stacklok.*namespace, and six legacytoolhive_*twins are removed. Their OTel-semconv replacements cover the same signals;docs/telemetry-migration-guide.mdmaps old → new. A customer scraping the old names loses those series (RFC D13 accepts this: the deleted twins are not scraped in a default shipped deployment).Special notes for reviewers
toolhive-corev0.0.34(the shared-vocabulary release); this PR'sgo.modpins it.toolhive-core v0.0.34trims theComponent*value roster a pre-release build referenced; toolhive defines its ownstacklok.component = "toolhive"constant (pkg/telemetry/middleware.go,pkg/telemetry/providers/providers.go), asserted bypkg/telemetry/buildinfo_test.go.pkg/vmcp/internal/backendtelemetry/backendtelemetry.go's health gauge resolves each backend's status with a three-tier precedence — a livehealth.StatusProviderfirst, then request-outcome tracking, then the registry's discovery-time snapshot — matching the same precedencefilterHealthyBackendsuses for capability aggregation.core.Newunregisters the gauge's callback on every construction-failure path after registration succeeds, and onClose(), so a partially-constructedcoreVMCPnever leaks a callback against the shared meter provider.Links
useLegacyAttributesspan-attribute dual-emission flag (out of scope here; metrics/labels in this PR use a hard cutover with no such flag).Ref: https://github.com/stacklok/stacklok-enterprise-platform/issues/1380
Generated with Claude Code