Dual-emit legacy metric names behind --otel-use-legacy-metrics - #6168
Open
glageju wants to merge 15 commits into
Open
Dual-emit legacy metric names behind --otel-use-legacy-metrics#6168glageju wants to merge 15 commits into
glageju wants to merge 15 commits into
Conversation
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.
glageju
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
blkt,
jerm-dro,
jhrozek,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
August 1, 2026 15:54
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.
# Conflicts: # pkg/telemetry/middleware.go
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.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## gautam/metrics-std-toolhive #6168 +/- ##
===============================================================
+ Coverage 72.64% 72.72% +0.08%
===============================================================
Files 736 737 +1
Lines 76447 76748 +301
===============================================================
+ Hits 55533 55816 +283
+ Misses 16997 16985 -12
- Partials 3917 3947 +30 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
5 tasks
Four paths reached the dual-emission layer with the wrong value, each defeating the compatibility window on a real deployment: - thv run rebuilt telemetry from raw flag values after resolving them, so a config-file `use-legacy-metrics: false` was silently flipped back to true in the persisted RunConfig, and again in rate-limit middleware. Resolved values now flow through, as they already did for tracing and metrics. - The operator read both legacy toggles inside the openTelemetry.Enabled guard, so a Prometheus-only MCPTelemetryConfig fell to the Go zero value and dropped every toolhive_* series on upgrade -- Prometheus scraping being where a legacy dashboard is most likely to exist. An explicit useLegacyMetrics: true was discarded the same way. - Standalone vMCP unmarshalled a plain bool straight from YAML with no defaulting layer, so any telemetry block omitting the key disabled dual emission. Load-time defaulting now distinguishes an omitted key from an explicit false, which the plain bool cannot express on its own. - toolhive_mcp_requests was incremented from two sites before the rename; the SSE branch returns early and never reached the alias, so the mcp_method="sse_connection" series read zero. Also restores toolhive_vmcp_backend_revision_reclassifications, a plain rename that shipped in v0.41.0 with no alias, and stops the backend aliases inheriting mcp.protocol.revision and caller span attributes from a shared slice -- neither of which the originals carried, and reusing it would change these frozen series again on the next attribute addition. Instrument-creation failures now log instead of vanishing: the guarantee is that an alias never blocks startup, not that its absence is undiagnosable. Drops dead nil-checks in ExecuteWorkflow whose stated justification did not hold, and shares one duration reading between an alias and its replacement so a side-by-side comparison agrees. Tests pin the optimizer un-merge, which had none: every legacy branch ran as a no-op because the fixture always disabled the flag. Verified by sabotage -- swapping not_found onto the errors counter fails, and so does renaming tool_name to mcp_tool_name, the regression an unanchored assertion cannot catch. The negative legacy assertions now set the flag explicitly rather than leaning on the zero value; flipping it fails all five, so they are load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o gautam/metrics-dual-emit-legacy-names # Conflicts: # pkg/vmcp/internal/backendtelemetry/backendtelemetry.go
Review of the dual-emission layer found four ways a legacy series could go missing or change shape on upgrade — the exact breakage the compatibility window exists to prevent. The vMCP backend aliases were rebuilt from a hand-written six-attribute subset. Before the rename they were recorded from commonAttrs after the caller's per-method attributes were appended, so they carried tool_name, gen_ai.tool.name, resource_uri, prompt_name, completion.*, auth.authenticated and mcp.protocol.revision. Dropping those collapses a dashboard grouping by tool_name to a single series. Snapshot commonAttrs instead. The comment defending the subset was wrong about the pre-rename code on both counts, so it is replaced rather than amended. Run configs persisted before these toggles existed carry no such key, and telemetry.Config holds them as plain bools, so every pre-upgrade workload decoded both as false and lost its toolhive_* series on the first restart. ReadJSON now re-probes the raw JSON to tell an absent key from an explicit false, alongside the self-heal migrations already there. The operator read the toggles outside the openTelemetry.enabled guard but still through plain bools, so correctness rested on admission having stamped the kubebuilder defaults; any spec that skipped admission fell to false. Carrying them as *bool makes "unset" representable, and leaves the generated CRD schema byte-identical. Existing converter cases asserted false as the expected default and encoded the bug, so they now assert the documented one. EnsureTelemetryLegacyDefaults force-enabled both toggles when its probe found no telemetry block, discarding an explicit false. Unreachable through the sole caller, but wrong if it ever is; leave the caller's values alone instead. Each fix is pinned by a regression test verified to fail when the fix is reverted.
The nine optimizer aliases were constructed inside the per-session factory closure while the six current instruments were built above it, so every session decoration re-entered the SDK's instrument registry. Hoist them into legacyOptimizerInstruments alongside the current ones, which also takes the instrument wiring out of monitorOptimizer. recordLegacyRequestMetrics claimed to run unconditionally on the strength of the instruments being no-ops, with an early return on the next line saying otherwise. The return is a real optimization — it skips three attribute sets per request — so describe it as one. The migration guide listed OTel instrument names for the legacy aliases but Prometheus names only for their replacements, so grepping a scrape for the name in the table found nothing for most rows; add the scraped form of all 23. Three emitted aliases appeared only as suffix fragments glued into another row and could not be found by name at all; give them their own rows. Merged counters said the separate aliases still fire but never gave the outcome-labelled query to migrate to, so add the translations and mark which pairs agree exactly and which differ by in-flight calls. Note that toolhive_vmcp_backend_revision_reclassifications is emitted regardless of useLegacyMetrics, since the flag table promised otherwise.
The kube-prometheus-stack Grafana service listens on 80, and the values file in examples/otel does not override it, so the documented 3000:3000 fails with "does not have a service port 3000" and leaves the UI unreachable for anyone following the skill. The adjacent Prometheus line is correct as written — that service does listen on 9090.
The self-heal in ReadJSON only reached the top-level telemetry_config, but that is not the copy the record sites read. Runner.Run skips PopulateMiddlewareConfigs whenever middleware_configs is already populated, and CreateMiddleware builds the provider from the parameters nested in that entry. Both the CLI builder and the operator persist it, so a runconfig written before these toggles existed still decoded them as false and dropped every toolhive_* series on the first restart after upgrade — the exact breakage the compatibility window exists to prevent. Probe the telemetry and rate-limit middleware entries alongside the top-level block and re-encode the ones whose persisted parameters omitted a toggle. The entry is matched on its own type rather than position alone, and parameters that fail to decode are left as they are. Rate-limit params also carried use_legacy_metrics as omitempty, which erased an explicit false on the way out and made it indistinguishable from a config predating the field — the heal would then turn emission back on against the user's wishes. Drop the option so the value always round-trips. Both fixes are pinned by regression tests verified to fail when reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#5956 renames ToolHive's metrics to the
stacklok.*vocabulary. Review blocked it on the same point twice: 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. 23 of the 24 pre-rename metric names are still emitted, under their original label keys. The exception is
toolhive_vmcp_backends_discovered, whose replacement is a live health gauge rather than a fire-once count, so there is nothing to reproduce — that one panel has to be migrated rather than waited out.pkg/telemetry/legacymetrics.go): helpers that return a working instrument when enabled and a no-op otherwise, so the record sites stay branch-free instead of growing anifeach. An instrument-creation failure also yields a no-op — a legacy alias must never be the reason a process fails to start.UseLegacyMetricsend to end — CLI flag, config file, operator CRD,thv serve, and vMCP — defaulting totrueat every layer.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 where the key vocabularies diverge, the legacy attribute set is built separately rather than shared.outcome-labelled counters. Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins #5956 folds four_errors/_not_foundcounters into anoutcomelabel (RFC D4). The aliases fan back out to the original separate counters, andfind_tooldeliberately 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.Type of change
Test plan
task test) — 259 packages, race detector ontask lint-fix) — 0 issuesVerified against a running proxy with
--otel-enable-prometheus-metrics-path:/metrics.--otel-use-legacy-metrics=false— zero^toolhive_series; new names still present.toolhive_rate_limit_decisions_totalstill carriesserver="…", notmcp_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 ofmcp_server="x".call_toolincrements both the newoutcome="not_found"series and the legacy_not_foundcounter.The un-merge is now also pinned by
TestTelemetryOptimizer_LegacyUnmergeFansOutToSeparateCounters, verified load-bearing by sabotage: wiringnot_foundonto the errors counter fails it, and so does renamingtool_nametomcp_tool_name— the regression an unanchored assertion cannot catch. The negative legacy-absence assertions now set the flag explicitly rather than leaning on the Go zero value; flipping it fails all five, so they can actually fail.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.What this PR changes. One optional field,
MCPTelemetryOTelConfig.UseLegacyMetrics, with+kubebuilder:default=true. Purely additive — no field is removed, and existing CRs are unaffected. Both it and the adjacentuseLegacyAttributesare*boolwithomitempty, so an unset key stays distinguishable from an explicitfalserather than resting on admission having stamped the default. The generated CRD schema is byte-identical either way —*bool+omitempty+ a kubebuilder default emits the same OpenAPI as a plainbool— so there is no schema churn and no stored CR is rewritten. This PR introduces no API break, and no migration is required for it.Why the label is applied anyway. The check fails for a pre-existing reason unrelated to this diff, so the three points above have no break of this PR's to describe:
status.referencingWorkloadsandstatus.referenceCountacross six config CRDs (mcpauthzconfigs,mcpexternalauthconfigs,mcpoidcconfigs,mcptelemetryconfigs,mcptoolconfigs,mcpwebhookconfigs).v0.41.0), and Remove referencingWorkloads/referenceCount from config CRD statuses #5631 removed those fields after that tag was cut. Every PR touchingdeploy/charts/operator-crds/files/crds/**inherits the errors; this PR touches that path only to add the field above.referencingWorkloadsappears zero times on this branch and zero times onmain, and twice per affected CRD inv0.41.0.The label should be removed once the next release moves the baseline past #5631, at which point the check goes green on its own for every PR.
Changes
pkg/telemetry/legacymetrics.gopkg/telemetry/config.goConfig.UseLegacyMetrics, default,(*Provider).UseLegacyMetrics()accessorpkg/telemetry/middleware.gomcp_method="sse_connection"seriespkg/telemetry/serve.gothv servecmd/thv/app/run_flags.go--otel-use-legacy-metrics;resolveOptionalBoolhelper; passes the resolved toggles toWithTelemetryConfigFromFlagspkg/config/config.goOpenTelemetryConfig.UseLegacyMetricspkg/runner/{config,telemetry_config,config_builder,middleware}.gotrue;ReadJSONdefaulting for configs persisted before the toggles existedpkg/ratelimit/{observability,limiter,middleware}.goserverlabelpkg/vmcp/core/core_telemetry.gopkg/vmcp/internal/backendtelemetry/backendtelemetry.gorevision_reclassificationsalias; legacy attribute set snapshotted fromcommonAttrspkg/vmcp/server/sessionmanager/factory.gooutcomeun-mergepkg/vmcp/{cli/serve,ratelimit/factory/limiter}.gocmd/thv-operator/pkg/spectoconfig/telemetry.goopenTelemetry.enabledguard, as*boolso unset ≠ falsepkg/vmcp/config/{defaults,yaml_loader}.gotrue, notfalsecmd/thv-operator/**,deploy/charts/**,docs/{cli,operator,server}/**docs/telemetry-migration-guide.md,docs/observability.mdDoes this introduce a user-facing change?
Yes, and it makes #5956's change non-breaking by default.
New flag
--otel-use-legacy-metrics(alsouse-legacy-metricsin config,spec.otel.useLegacyMetricson the CRD), defaulting totrue. While on, ToolHive emits both the newstacklok.*metric names and the pre-renametoolhive_*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
falseto drop the legacy series. The flag and the legacy names will be removed in a later minor — seedocs/telemetry-migration-guide.mdfor the old → new mapping and the schedule.Dual emission roughly doubles series count for the metrics involved while enabled; histograms are the expensive case.
Special notes for reviewers
On RFC D13 ("no dual-emit window"). Reviewers on #5956 raised this and I agreed: a
dual-emit window is necessary for ToolHive OSS, so that users' existing dashboards don't
break the moment they upgrade. D13 argued the legacy twins aren't scraped in a default
shipped deployment — but this repo ships four Grafana dashboards under
examples/otel/grafana-dashboards/, and D13's own closing sentence anticipates exactlythis case: "If a scraped consumer is identified before the rename lands, revisit this
decision for that surface."
The RFC is unedited here; if D13 should now read as allowing the window, that's a
one-line amendment rather than a code change.
Seven config paths reach the dual-emission layer with the wrong value. Each
defeats the compatibility window on a real deployment, and all seven are fixed
here rather than left for a follow-up, since a flag that silently ignores you is
worse than no flag:
thv runre-derived telemetry from raw flag values after resolving them, so aconfig-file
use-legacy-metrics: falsewas flipped back totruein the persistedRunConfig— and again in rate-limit middleware, giving a half-disabled state.openTelemetry.enabledguard, so aPrometheus-only
MCPTelemetryConfigfell to the Go zero value and dropped everytoolhive_*series on upgrade. Prometheus scraping is where a legacy dashboard ismost likely to exist, so this defeated the feature exactly where it mattered most.
An explicit
useLegacyMetrics: truewas discarded the same way.boolstraight from YAML with no defaultinglayer, so any
telemetry:block omitting the key disabled dual emission silently.toolhive_mcp_requestswas incremented from two sites pre-rename; the SSE branchreturns early, so the
mcp_method="sse_connection"series read zero.RunConfighas no defaulting, so arunconfig.jsonwritten before thesetoggles existed decodes both as the Go zero value
false— dropping everytoolhive_*series on the first restart after upgrade, for exactly the pre-upgrade workloads the
window exists to protect. Live on
thv restart,thv run --from-config, andthv-proxyrunner.
ReadJSONre-probes the raw JSON to tell an absent key from anexplicit
false.nested one.
Runner.RunskipsPopulateMiddlewareConfigswhenevermiddleware_configsis already populated, andCreateMiddlewarebuilds the providerfrom the parameters inside that entry rather than from the top-level
telemetry_config. Both the CLI builder and the operator persist it, so healing thetop-level block alone leaves the record sites reading
false. The re-probe covers thetelemetry and rate-limit entries too, matching on middleware type and leaving
parameters it cannot decode untouched.
use_legacy_metricsasomitempty, which erased anexplicit
falseon the way out and made it indistinguishable from a config predatingthe field — so the re-probe in 6 would turn emission back on against the user's wishes.
The option is dropped so the value always round-trips.
Deliberate design choices, in case they look wrong at a glance:
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.gotakes the metric name as a parameter and contains no name literals, so the seam has no opinion about what's being aliased.token_savingsis the one row where the rename is nearly invisible. Both the new metric and its alias carry unit%, so they export asstacklok_vmcp_optimizer_token_savings_percentandtoolhive_vmcp_optimizer_token_savings_percent— the new Prometheus name differs from the old only in prefix, and the exporter does not double the_percentsuffix on the alias. Settled rather than assumed: confirmed against a live Prometheus exporter, and the guard isotlptranslator's!strings.HasSuffix(name, mainUnitSuffix)check inmetric_namer.go. The metric name still changed, so the query does need migrating — only the suffix coincides.Still open: the removal release is unnamed. The guide's deprecation timeline says "current release / future release / later release" rather than literal versions, matching how
useLegacyAttributesis already documented. Two things depend on settling that version, and I'd rather settle it before shipping either: pinning actual release numbers in the timeline, and the startup deprecation warning raised in review. 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.Known gap, not fixed here (pre-existing, and orthogonal to dual emission):
stacklok.build_infoexports asstacklok_build_info_ratio, becausecoremetrics.RegisterBuildInfosetsmetric.WithUnit("1")and the Prometheus translator appends a unit suffix._ratiois wrong for an identity gauge, and it means a dashboard joining onstacklok_build_inforeturns empty. The durable fix is upstream intoolhive-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