Skip to content

Fix MetricsExporter mixing up instruments and labels when object ids are reused - #2037

Open
sohom-cs wants to merge 2 commits into
temporalio:mainfrom
sohom-cs:fix/metrics-exporter-reused-objects
Open

sohom-cs wants to merge 2 commits into
temporalio:mainfrom
sohom-cs:fix/metrics-exporter-reused-objects

Conversation

@sohom-cs

@sohom-cs sohom-cs commented Oct 29, 2026 •

Copy link
Copy Markdown

What was changed

MetricsExporter (temporalio/contrib/opentelemetry/_metrics_exporter.py) caches converted attributes by id(attributes) and OTel instruments by id(metric), but keeps neither object alive. When Core drops an attribute set or a metric, its Python object is freed, the next one can get the same address, and the exporter then hands back the old labels or the old instrument.

This change makes each cache entry hold its object, so the id can't be reused while the entry is cached, and bounds both caches at 1000 entries (a full cache starts over). An attribute entry also holds the gauge key, the attribute items as a frozenset, built on the first gauge update, so a gauge's last values are keyed by contents without rebuilding the key on every update. Behind the metric cache, instruments are keyed by (name, kind), so instruments created per call share one OTel instrument. That also fixes gauges created per call: OTel ignores the callback of a second create_observable_gauge with the same name, so on main such a gauge exported only the first call's value.

Why?

The buffer creates new objects all the time. The bridge makes a new Python dict per attribute set and a new BufferedMetric per instrument (temporalio/bridge/src/metric.rs), and Core makes a fresh attribute set on every new_attributes/extend_attributes call, for example to record activity_execution_latency for each activity completion. User code does the same with activity.metric_meter().with_additional_attributes({...}) or by creating instruments per call. The runtime docs promise the same object across updates for the same attribute set, which holds only while that object is alive.

What it looks like: three calls creating custom metrics per call (gpt-a with 100 tokens and 900 ms, llama-b with 7 and 40, qwen-c with 55 and 300). One run on main exported llm_tokens{model="gpt-a"} = 195 and a llm_latency_ms{model="gpt-a"} histogram with count 3 and sum 1207: llama-b's latency went into the token counter and its token count into the histogram. With this change: 100, 7 and 55, and one latency sample per model.

Cost (debug bridge build, median of 15): with 20 long-lived attribute sets, applying 102k buffered updates takes 277 ms (main: 278). With a new attribute set per call, as Core's per-activity metrics make, 68k updates take 261 ms (main: 235), because the id caches can't hit there.

Checklist

  1. Closes: no issue (small bug fix).

  2. How was this tested:

    • New test_metrics_exporter_instruments_created_per_call creates a counter, a histogram and a gauge under {"model": ...} in three calls, drains, and checks every value. On main it fails (for example {'model-a': 162} != {'model-a': 100}; 30 of 30 runs with pytest-flakefinder); with the change it passes (30 of 30).
    • New test_metrics_exporter_caches_are_bounded caps both caches at 2 entries, sends five attribute sets twice, and checks that the caches stay within 2 entries and that every value is right after eviction.
    • poe lint passes. tests/contrib/opentelemetry: 88 passed.
    • Real worker (mac_core_metrics_check.py, two activity types, six runs each): activity_execution_latency samples by activity_type were {'None': 1, 'beta': 6, 'alpha': 5} with the original exporter and {'alpha': 6, 'beta': 6} with this change.
  3. Any docs updates needed? No. Changelog fragment: changelog/fixed/mislabeled-marmoset.md.

@sohom-cs
sohom-cs requested a review from a team as a code owner October 29, 2026 02:09
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-29T02:10:29.411426Z
We couldn’t complete this review. Comment @codex review to try again.
8e4dd6a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@CLAassistant

CLAassistant commented Oct 29, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment on lines +139 to +140
# Not keyed by id(): the buffer frees a metric or attribute set once
# Core drops it, and a later one can then get the same id().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's remove the comment here about the previous implementation.

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.

Removed in a468e66.

assert instrument.gauge_state is not None
with instrument.gauge_state.lock:
instrument.gauge_state.last_values[id(update.attributes)] = (
instrument.gauge_state.last_values[frozenset(attributes.items())] = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should avoid computing this frozenset each update. But I do think we should use the content based key.

Let's restore the attribute cache and precompute this in a bounded cache. Maybe something like this?

id(attrs) -> (
    attrs, # keep ref to avoid id reuse
    dict(attrs), # passed to otel
    frozenset(dict(attrs)), # used as gauge values cache key
)

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.

Done in a468e66, for attributes and for instruments.
Each entry holds its object, so the id can't be reused while it is cached; at 1000 entries a cache starts over. An attribute entry holds the dict passed to OTel and the gauge key, built on the first gauge update (counters and histograms don't use it).

One change from the sketch: the key is frozenset(attrs.items()). frozenset(dict(attrs)) would hold only the keys, so {"model": "a"} and {"model": "b"} would share one gauge value.

Timing, previous commit → now (main in brackets; median of 15, debug bridge build):

  • 20 long-lived attribute sets, 102k updates: 317 → 277 ms (278).
  • a new attribute set per call, as Core's per-activity metrics make, 68k updates: 242 → 261 ms (235). The cache can't hit there, so each update pays the dict and an insert.

Most of the gain is the instrument cache in front of (name, kind). With it alone, without the attribute cache, the two cases measured 289 and 221 ms, so I can drop the attribute cache if you'd rather.

@VegetarianOrc VegetarianOrc self-assigned this Oct 29, 2026
Each entry keeps its object alive, so the id can't be reused while the
entry is cached, and both caches are bounded: a full cache starts over.
An attribute entry also keeps the gauge key once a gauge update has built
it. Instruments are still shared by (name, kind) behind the metric cache.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants