Skip to content

Dual-emit legacy metric names behind --otel-use-legacy-metrics - #6168

Open
glageju wants to merge 9 commits into
gautam/metrics-std-toolhivefrom
gautam/metrics-dual-emit-legacy-names
Open

Dual-emit legacy metric names behind --otel-use-legacy-metrics#6168
glageju wants to merge 9 commits into
gautam/metrics-std-toolhivefrom
gautam/metrics-dual-emit-legacy-names

Conversation

@glageju

@glageju glageju commented Aug 1, 2026

Copy link
Copy Markdown

Summary

Stacked on #5956. Base is gautam/metrics-std-toolhive, so this diff shows only the dual-emission layer. Review #5956 first; merge it first.

#5956 renames ToolHive's metrics to the stacklok.* vocabulary. @aponcedeleonch and @ChrisJBurns both blocked it on the same point: a rename empties a dashboard panel exactly the way a deletion does, and the renamed metrics have no overlap window — the old name stops and the new one starts in the same release, so there is no moment where a user can run both queries side by side and confirm they agree.

Span attributes already have machinery for exactly this: dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Metrics went with a hard cutover instead, and the asymmetry wasn't justified. This PR closes it — metrics get the same treatment: a flag, a window, and a documented path.

After this PR, upgrading #5956 breaks nothing by default. All 25 pre-rename metric names are still emitted, under their original label keys.

  • Adds the dual-emit seam (pkg/telemetry/legacymetrics.go): helpers that return a working instrument when enabled and a no-op otherwise, so the ~25 record sites stay branch-free instead of growing an if each. An instrument-creation failure also yields a no-op — a legacy alias must never be the reason a process fails to start.
  • Threads UseLegacyMetrics end to end — CLI flag, config file, operator CRD, thv serve, and vMCP — defaulting to true at every layer.
  • Emits legacy aliases for all 25 old names across proxy, rate-limit, and vMCP metrics, including the six §3.5 semconv twins that Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins #5956 deletes outright. Those cost nothing new to keep: they already coexist with their semconv replacements today.
  • Aliases keep their original label keys. This matters more than it looks: the rate-limit metrics change both name and label (toolhive_rate_limit_decisions{server=…}stacklok_toolhive_ratelimit_decisions_total{mcp_server=…}), so those dashboards break twice over. An alias emitting the old name with the new label key would be a second breaking change, not a compatibility path — so the legacy attribute sets are built separately rather than shared.
  • Un-merges the outcome-labelled counters. Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins #5956 folds four _errors/_not_found counters into an outcome label (RFC D4). The aliases fan back out to the original separate counters, and find_tool deliberately keeps its legacy counter incrementing before the call — the original counted attempts, not completions, so it's reproduced as-was rather than silently changing meaning during the overlap window.
  • Reframes the migration guide from a one-shot cutover into a deprecation schedule, with the old → new mapping and the removal plan.

Type of change

  • New feature

Test plan

  • Unit tests (task test) — 199 packages, race detector on
  • Linting (task lint-fix) — 0 issues
  • Manual testing (described below)

Verified against a running proxy with --otel-enable-prometheus-metrics-path:

  • Default (flag on) — both name families present in /metrics.
  • --otel-use-legacy-metrics=false — zero ^toolhive_ series; new names still present.
  • Label compatibilitytoolhive_rate_limit_decisions_total still carries server="…", not mcp_server="…". This is the check that would catch the "breaks twice over" regression, and an unanchored assertion can't catch it: server="x" is a substring of mcp_server="x".
  • Un-merge — a not-found call_tool increments both the new outcome="not_found" series and the legacy _not_found counter.

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.

Adds one optional field, MCPTelemetryOTelConfig.UseLegacyMetrics, with +kubebuilder:default=true. Purely additive — no field is removed or retyped, and existing CRs are unaffected.

Does this introduce a user-facing change?

Yes, and it makes #5956's change non-breaking by default.

New flag --otel-use-legacy-metrics (also use-legacy-metrics in config, spec.otel.useLegacyMetrics on the CRD), defaulting to true. While on, ToolHive emits both the new stacklok.* metric names and the pre-rename toolhive_* names with their original label keys, so existing dashboards and alerts keep working across the upgrade.

Migrate at your own pace: run the old and new queries side by side, confirm they agree, then set the flag to false to drop the legacy series. The flag and the legacy names will be removed in a later minor — see docs/telemetry-migration-guide.md for the old → new mapping and the schedule.

Dual emission roughly doubles series count for the metrics involved while enabled; histograms are the expensive case.

Changes

File Change
pkg/telemetry/legacymetrics.go New. The three dual-emit helpers
pkg/telemetry/config.go Config.UseLegacyMetrics, default, (*Provider).UseLegacyMetrics() accessor
pkg/telemetry/middleware.go Proxy legacy aliases; startup deprecation warning
pkg/telemetry/serve.go Flag default on thv serve
cmd/thv/app/run_flags.go --otel-use-legacy-metrics; resolveOptionalBool helper
pkg/config/config.go OpenTelemetryConfig.UseLegacyMetrics
pkg/runner/{telemetry_config,config_builder,middleware}.go Threading; nil → true
pkg/ratelimit/{observability,limiter,middleware}.go Rate-limit aliases keeping the server label
pkg/vmcp/core/core_telemetry.go Composite-tool aliases
pkg/vmcp/internal/backendtelemetry/backendtelemetry.go Backend aliases
pkg/vmcp/server/sessionmanager/factory.go 9 optimizer aliases; outcome un-merge
pkg/vmcp/{cli/serve,ratelimit/factory/limiter}.go vMCP threading
cmd/thv-operator/**, deploy/charts/**, docs/{cli,operator,server}/** CRD field + regenerated
docs/telemetry-migration-guide.md, docs/observability.md Deprecation schedule

Special notes for reviewers

On RFC D13 ("no dual-emit window"). I don't think this PR contradicts it, for two reasons worth checking rather than taking on faith:

  1. D13 scopes itself to "the rename to stacklok.* and the deletion of the six legacy twins", and its supporting argument is entirely about those twins not being scraped in a default shipped deployment. That argument covers the 6 deletions. It doesn't reach the 15 renames, which are the larger share.
  2. D13's own closing sentence pre-authorizes this: "If a scraped consumer is identified before the rename lands, revisit this decision for that surface." This repo ships four Grafana dashboards under examples/otel/grafana-dashboards/, which is at minimum evidence of intended scraped consumption.

@reyortiz3 — as RFC author, could you confirm whether D13's scope was meant to cover the renames or only the twins? One sentence settles whether this needs an RFC amendment or just a note. I've deliberately not edited the RFC.

Deliberate design choices, in case they look wrong at a glance:

  • Aliases carry the old label keys, not the new ones. Looks like an inconsistency in the diff; it's the whole point. See the rate-limit case above.
  • find_tool's legacy counter increments before the call. Also looks inconsistent next to the new outcome counter, which increments after. The original counted attempts, so reproducing it faithfully means keeping that placement.
  • legacymetrics.go takes the metric name as a parameter and contains no name literals, so the seam has no opinion about what's being aliased.
  • token_savings is the one row where the rename is nearly invisible. Both the new metric and its alias carry unit %, so they export as stacklok_vmcp_optimizer_token_savings_percent and toolhive_vmcp_optimizer_token_savings_percent — the new Prometheus name differs from the old only in prefix, and the exporter does not double the _percent suffix on the alias (verified against a real scrape). Worth a sanity check by a second pair of eyes.

Extra scrutiny welcome on two things:

  1. The removal release is unnamed. The guide's deprecation timeline currently says "current release / future release / later release" rather than literal versions, matching how useLegacyAttributes is already documented. Two things are therefore still open, and I'd rather settle the version before shipping either: pinning actual release numbers in the timeline, and the startup deprecation warning @aponcedeleonch and @ChrisJBurns both asked for. A warning that says "a future release" gives users nothing to plan against, so I left it out pending a number. Happy to add both here.
  2. Whether the six §3.5 twins should also be retained. This PR keeps them, per the review. If you'd rather Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins #5956's deletion stand and only the 15 renames get aliases, that's a smaller diff and I can cut it back.

Known gap, not fixed here (pre-existing, and orthogonal to dual emission): stacklok.build_info exports as stacklok_build_info_ratio, because coremetrics.RegisterBuildInfo sets metric.WithUnit("1") and the Prometheus translator appends a unit suffix. _ratio is wrong for an identity gauge, and it means a dashboard joining on stacklok_build_info returns empty. The durable fix is upstream in toolhive-core. Happy to file it, or fold it in here if you'd rather it not reach customer dashboards at all.

🤖 Generated with Claude Code

glageju added 4 commits July 30, 2026 17:03
Renaming a metric empties a dashboard panel exactly as deleting it does, and
the rename half of this migration shipped with no overlap window: the old name
stopped and the new name started in the same release, so there was no moment
where an operator could run both queries and confirm they agree before cutting
over. The six deleted twins had that overlap by accident — their semconv
replacements were already emitting alongside them before this branch.

This restores the overlap deliberately, behind a flag defaulting to on:

- toolhive_mcp_requests, toolhive_mcp_request_duration and
  toolhive_mcp_tool_calls are re-emitted under their original names and label
  vocabulary, including the pre-rename "any status >= 400 is an error"
  classification — an alias that reports different numbers than the metric it
  replaces is worse than no alias.
- toolhive_mcp_active_connections is re-emitted under the old server/transport
  label keys alongside the renamed gauge.

The flag mirrors --otel-use-legacy-attributes at every layer (CLI flag, file
config as *bool, runner builder, telemetry.Config, operator CRD, spectoconfig
mapping and its drift-test entry) and is passed explicitly rather than through
package state, matching how every other --otel* setting reaches its consumers.

Legacy instruments are no-ops when the flag is off, so record sites need no
branch, and an instrument-creation failure also yields a no-op — a legacy alias
must never be why a process fails to start.

Tests pin both states: with the flag on, legacy and current names appear in the
same scrape; with it off, the legacy names are absent while the current ones are
unaffected.

Addresses the cutover-strategy request in #5956 from
@ChrisJBurns and @aponcedeleonch. Rate-limit, vMCP and optimizer renames are
not yet dual-emitted.
The rate-limit renames break dashboards twice over: both the metric name and
its server label changed (toolhive_rate_limit_decisions{server=…} became
stacklok_toolhive_ratelimit_decisions_total{mcp_server=…}), so a panel grouping
by the old label returns nothing even if it finds the series.

All three rate-limit metrics are now re-emitted under their pre-rename names
with the pre-rename "server" label key, gated by the same
--otel-use-legacy-metrics flag as the proxy metrics. The flag reaches
pkg/ratelimit explicitly: through MiddlewareParams (which is JSON-serialized to
the proxyrunner) and the vMCP factory Config, sourced from TelemetryConfig at
both construction sites, rather than through package state.

TestRateLimitMetricNamesUseStacklokPrefix now constructs with the flag off, so
it still proves the rename is complete and additionally covers suppression; a
new test covers the enabled case and asserts the legacy series carries the old
label key.

Part of the cutover-strategy request in #5956.
Completes dual emission across the remaining renamed metrics — composite-tool
executions/duration, the deleted backend request/error/duration twins, and the
nine optimizer metrics — so every renamed or retired name has an overlap window,
not just the proxy ones.

Where counters were merged into an outcome label, the legacy aliases are
re-emitted as the separate counters they were (workflow errors, optimizer
find_tool/call_tool errors and not_found), and each alias keeps its original
label vocabulary (workflow.name, tool_name, the target.* set) so an existing
dashboard or alert reads its own series rather than a renamed one. Aliases
reproduce the originals' semantics exactly, including counting attempts rather
than completions where the originals did.

The flag reaches these packages explicitly via Provider.UseLegacyMetrics() and a
MonitorBackends/monitorOptimizer parameter, sourced from the Config the provider
was built with so it cannot drift.

telemetryComposer's legacy record calls are nil-checked: workflowInstruments is
also built by struct literal in-package, where those fields are unset.

The migration guide is reframed from a one-shot cutover to a deprecation
schedule: the Backward Compatibility table no longer claims metrics are a hard
cutover, Step 1 no longer promises dashboards keep working indefinitely, Step 4's
side-by-side verification advice is now actually true for metrics, and Step 5
retires both flags. Two things dual emission cannot cover are called out — bucket
boundary changes and the merged-counter semantics above.

Addresses #5956:
- HIGH docs/telemetry-migration-guide.md:213 (3685784090): Step 1 no longer
  states existing dashboards continue to work without changes; the window is
  framed as temporary with a removal release, and Step 4 is rescoped.

Completes the cutover-strategy request from @ChrisJBurns and @aponcedeleonch.
Self-review of the preceding commits found one real defect and several
comments that the changes had invalidated.

thv serve built its telemetry.Config by struct literal without setting either
legacy-emission field, so both fell to the Go zero value of false. The
documented default is true, and the config fields are *bool precisely so
"unset" is distinguishable from an explicit false — so dual emission was
silently off on that path, with no error and no log line, just missing legacy
series. UseLegacyAttributes had the same pre-existing gap; both are now
resolved through boolOrDefaultTrue, with a test pinning the rule.

Comments corrected where the code had moved out from under them:
- backendtelemetry.go: two comments still said the recorded-health map is
  "never pruned" and that set() can only ever receive
  BackendHealthy/BackendUnhealthy. retain() prunes it and
  healthStatusForError can now yield BackendUnauthenticated.
- middleware.go: recordHTTPServerDuration's attribute list omitted
  network.protocol.version, which it emits.
- middleware.go: buildInfoOnce's comment covered the duplicate-callback case
  but not the inverse — with two distinct meter providers in one process only
  the first gets the gauge. Not reachable today (one telemetry middleware per
  proxy process, one provider in vMCP), now stated rather than left implicit.
- observability.md: the gen_ai cardinality warning called the toolhive_mcp_*
  twins "deleted", contradicting the same document's useLegacyMetrics entry;
  reworded to describe when it becomes true. The
  http.server.request.duration attribute table gained url.scheme and
  network.protocol.version, and notes the _OTHER normalization.
- limiter.go: NewLimiter hardcodes legacy emission on and takes no telemetry
  config; its doc now says so and points at NewRedisLimiter.
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Aug 1, 2026
glageju added 3 commits August 1, 2026 09:03
Six legacy aliases re-emitted their original metric name on
BucketsMCPProxy, but those names shipped on BucketsMCPSemconv -- the
pre-rename MCPHistogramBuckets was the semconv preset. Boundaries are not
dual-emitted: there is one series per name, so an alias that moves them
breaks the exact query the overlap window exists to protect.

An operator upgrading with --otel-use-legacy-metrics=true expects existing
dashboards to keep working unchanged. Instead le="0.02"/"0.2"/"2" stop
being written and le="0.25"/"2.5" start, so histogram_quantile() over a
range spanning the upgrade mixes two layouts and returns a plausible,
wrong number for the whole retention window -- no error, no gap in the
graph.

Affects toolhive_mcp_request_duration, _vmcp_backend_requests_duration,
_vmcp_workflow_duration, _vmcp_optimizer_{find,call}_tool_duration, and
_rate_limit_check_latency. The new names keep the presets they were
assigned; only the aliases move back.
@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
The PR-split planning notes under docs/plans/ were working material, not
part of the change; they were picked up by an over-broad `git add` while
resolving a merge conflict. Removed from version control and kept locally.
@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
@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
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.17094% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.69%. Comparing base (3f960e6) to head (56f55e0).

Files with missing lines Patch % Lines
pkg/vmcp/cli/serve.go 0.00% 8 Missing ⚠️
pkg/telemetry/legacymetrics.go 71.42% 3 Missing and 3 partials ⚠️
pkg/runner/middleware.go 77.77% 1 Missing and 1 partial ⚠️
pkg/telemetry/middleware.go 96.82% 1 Missing and 1 partial ⚠️
pkg/telemetry/serve.go 50.00% 2 Missing ⚠️
pkg/vmcp/server/sessionmanager/factory.go 96.00% 2 Missing ⚠️
pkg/telemetry/config.go 83.33% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           gautam/metrics-std-toolhive    #6168      +/-   ##
===============================================================
+ Coverage                        72.68%   72.69%   +0.01%     
===============================================================
  Files                              736      737       +1     
  Lines                            76447    76650     +203     
===============================================================
+ Hits                             55563    55724     +161     
- Misses                           16951    16985      +34     
- Partials                          3933     3941       +8     

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

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.

1 participant