Skip to content

Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins - #5956

Open
glageju wants to merge 37 commits into
mainfrom
gautam/metrics-std-toolhive
Open

Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins#5956
glageju wants to merge 37 commits into
mainfrom
gautam/metrics-std-toolhive

Conversation

@glageju

@glageju glageju commented Jul 23, 2026

Copy link
Copy Markdown

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 shared toolhive-core vocabulary, per the Metrics Standardization RFC.

  • Proxy (pkg/telemetry/): toolhive_mcp_active_connectionsstacklok.toolhive.proxy.active_connections; build_info via coremetrics.RegisterBuildInfo; added http.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 via WithResourceAsConstantLabels and hardened as the final OTEL resource detector so a CLI custom attribute or OTEL_RESOURCE_ATTRIBUTES entry for those keys cannot override the frozen values.
  • vMCP (pkg/vmcp/): toolhive_vmcp_workflow_*stacklok.vmcp.composite_tool.*; backend mcp_server.health gauge 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.
  • ⚠️ Breaking: six legacy semconv twins deleted (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.
  • Bumps the toolhive-core dependency to v0.0.34, the release carrying telemetry/metrics (adopts core label keys, outcome values, and bucket presets). The transitive AWS-SDK and grpc bumps in go.mod come from toolhive-core's own requirements.
  • Updates integration and e2e tests (test/integration/vmcp/, test/e2e/) that asserted the deleted/renamed metric names and old label scheme (mcp_methodmcp_method_name, servermcp_server, status→HTTP status code) to assert the semconv replacements.
  • Updates the Grafana example dashboards and docs/telemetry-migration-guide.md for the renamed/deleted metrics, including a complete old→new metric and label mapping and a corrected MCP Request Rate vs. total HTTP request-rate query split.
  • Enables the Grafana dashboard sidecar in the example otel dev stack (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.
  • Extends the rename to pkg/ratelimit/observability.go, missed by the original pass: toolhive_rate_limit_decisions/_redis_errors/_check_latencystacklok.toolhive.ratelimit.{decisions,redis_errors,check_latency}, with the migration guide and docs/observability.md updated to match.
  • Renames toolhive_vmcp_backend_revision_reclassifications (added independently on main after this branch forked) to stacklok.vmcp.backend.revision_reclassifications so it doesn't reintroduce the legacy naming scheme this PR retires.
  • Clarifies 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

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Verified the shipped standalone path (GOWORK=off, published toolhive-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 in pkg/telemetry/buildinfo_test.go (stacklok_component="toolhive" / stacklok_product="stacklok-platform" promoted to per-series labels) and the updated TestVMCPServer_Telemetry_CompositeToolMetrics integration test.
  • task test (full suite) is green.
  • The e2e telemetry tests (test/e2e/) were updated for the new metric/label vocabulary and compile clean (go vet); they require a full task test-e2e run (containers) to exercise end-to-end.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label 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 legacy toolhive_* twins are removed. Their OTel-semconv replacements cover the same signals; docs/telemetry-migration-guide.md maps 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

  • Depends on toolhive-core v0.0.34 (the shared-vocabulary release); this PR's go.mod pins it.
  • toolhive-core v0.0.34 trims the Component* value roster a pre-release build referenced; toolhive defines its own stacklok.component = "toolhive" constant (pkg/telemetry/middleware.go, pkg/telemetry/providers/providers.go), asserted by pkg/telemetry/buildinfo_test.go.
  • pkg/vmcp/internal/backendtelemetry/backendtelemetry.go's health gauge resolves each backend's status with a three-tier precedence — a live health.StatusProvider first, then request-outcome tracking, then the registry's discovery-time snapshot — matching the same precedence filterHealthyBackends uses for capability aggregation. core.New unregisters the gauge's callback on every construction-failure path after registration succeeds, and on Close(), so a partially-constructed coreVMCP never leaks a callback against the shared meter provider.

Links

Ref: https://github.com/stacklok/stacklok-enterprise-platform/issues/1380
Generated with Claude Code

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026
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.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.25352% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.64%. Comparing base (a48448d) to head (d4e44a2).

Files with missing lines Patch % Lines
pkg/telemetry/middleware.go 84.14% 9 Missing and 4 partials ⚠️
pkg/vmcp/core/core_vmcp.go 87.87% 2 Missing and 2 partials ⚠️
...vmcp/internal/backendtelemetry/backendtelemetry.go 96.42% 3 Missing ⚠️
pkg/telemetry/providers/providers.go 90.90% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
glageju added 2 commits July 24, 2026 11:05
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)
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
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.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 30, 2026
@glageju

glageju commented Jul 30, 2026

Copy link
Copy Markdown
Author

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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 30, 2026

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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-metrics flag 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

  1. Histogram bucket boundaries change on metrics whose names survive. mcp.server.operation.duration moves from BucketsMCPSemconv() […0.02, 0.2, 2…] to BucketsMCPProxy() […0.25, 2.5…]. Any histogram_quantile() spanning the upgrade mixes two incompatible layouts and returns silently wrong numbers. This is also the unresolved #6144 conflict — see the inline comment.
  2. telemetry.MCPHistogramBuckets is deleted from a public package. Exported identifier in github.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.
  3. stacklok.build_info is documented under a name Prometheus never emits. It exports as stacklok_build_info_ratio (the WithUnit("1")_ratio rule). 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:246 and :974 — the semconv dashboard's own Error Rate panels still use error_type!="", which is exactly the 4xx trap the migration guide warns about. The other three dashboards were correctly moved to http_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 removed mcp_method label. CLAUDE.md requires docs/arch/ to be updated when architecture changes.
  • .claude/agents/site-reliability-engineer.md:23 — still points at the deleted pkg/vmcp/server/telemetry.go.
  • Three rewritten dashboards lack sum by (...)rate(mcp_server_operation_duration_seconds_count{…}) with legendFormat: "{{mcp_method_name}}" and no aggregation fans tools/call out 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 and revision_reclassifications metrics, 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

Comment thread pkg/telemetry/middleware.go Outdated
ctx context.Context, method string, statusCode int, duration time.Duration,
) {
specAttrs := []attribute.KeyValue{
attribute.String("http.request.method", method),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 09b576f.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread pkg/telemetry/middleware.go Outdated
metric.WithDescription("Duration of MCP server operations"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(MCPHistogramBuckets...),
metric.WithExplicitBucketBoundaries(coremetrics.BucketsMCPProxy()...),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 09b576f.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread pkg/telemetry/buildinfo_test.go Outdated
handler.ServeHTTP(rw, req)
body := rw.Body.String()

require.Contains(t, body, "stacklok_build_info", "build_info metric should be exported")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@glageju glageju Aug 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread docs/observability.md Outdated

#### `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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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/list result. telemetry.CreateMiddleware's only window onto wider state is types.RunnerConfig, which is GetName() + GetPort(). Reaching RunConfig.ToolsFilter needs a type assertion, which .claude/rules/go-style.md calls out explicitly.
  • --tools is user-supplied and skipped entirely when empty (pkg/runner/middleware.go:129), so it bounds nothing for most workloads. ToolsOverride also means the wire name is not the backend name.
  • vMCP has a real set (RoutingTable.Tools), but only behind core.ListTools(ctx, identity) — async, per-identity, 30s TTL, invalidated by list_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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread pkg/telemetry/integration_test.go Outdated
// 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 70302f0a5checkedLines 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) ---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 servermcp_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.

@glageju glageju Aug 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.duration
  • stacklok.toolhive.proxy.sse_connection.duration (added in 46864d8e)
  • stacklok.build_info
  • stacklok.vmcp.mcp_server.health — semantically new, not a rename of toolhive_vmcp_backends_discovered
  • the new outcome label 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

glageju added 6 commits July 30, 2026 15:54
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.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 1, 2026
glageju added 4 commits August 1, 2026 09:13
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.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 1, 2026
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.
@github-actions github-actions Bot removed the size/XL Extra large PR: 1000+ lines changed label Aug 1, 2026
@glageju

glageju commented Aug 1, 2026

Copy link
Copy Markdown
Author

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:

  • 3685784128 (cancellation): the guard I added matched context.Canceled, but the client severs that chain with %v and wraps only the domain sentinel with %w. It compiled, read correctly, and never fired. Fixed in ca7a252a2 against vmcp.ErrCancelled/ErrTimeout.
  • 3685784106 (build_info): my process-wide sync.Once fixed the duplicate callbacks and made every provider after the first export no build_info at all. Fixed in 6f3c6c882, keyed per MeterProvider.

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 d4e44a27d cleans up.

Three things resolved outside this PR, with reasons in the individual replies:

One design decision I've made rather than deferred: 3685784135 — keeping HealthProviderSetter. The false justification is fixed; the real reason is that it keeps the gauge's unregister func acquired at one point early in New, and reordering would push more error paths above the registration — the fragility you flagged in 3685784145.

Everything else in your review is addressed on the branch.

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

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants