Skip to content

Add OpenTelemetry metrics exporter built on MetricBuffer - #1701

Open
harsh543 wants to merge 2 commits into
temporalio:mainfrom
harsh543:feat/otel-metrics-exporter-1049
Open

Add OpenTelemetry metrics exporter built on MetricBuffer#1701
harsh543 wants to merge 2 commits into
temporalio:mainfrom
harsh543:feat/otel-metrics-exporter-1049

Conversation

@harsh543

@harsh543 harsh543 commented Jul 30, 2026

Copy link
Copy Markdown

What was changed

Adds MetricsExporter to temporalio.contrib.opentelemetry: drains a temporalio.runtime.MetricBuffer on a fixed interval and exports through a real OpenTelemetry MeterProvider.

  • Counters map to Counter.add() (buffered counter values are deltas).
  • Histograms map to Histogram.record().
  • Gauges map to create_observable_gauge() backed by a lock-protected last-value cache, since OTel invokes gauge callbacks from its own export thread concurrently with the drain loop.
  • Instrument and attribute objects are cached keyed by id(), exploiting the identity-stability guarantee BufferedMetric/BufferedMetricUpdate.attributes already document.
  • Lifecycle (run()/shutdown()/async with) mirrors Worker's existing asyncio.Event-based shutdown pattern, since that's the only background-polling precedent already in this codebase (no threading precedent exists in the Python layer).
  • retrieve_updates() raising RuntimeError (buffer never attached to a constructed Runtime) propagates out of run() rather than spinning silently. Any other per-update failure is isolated, logged, and optionally reported via an on_error callback, without blocking the rest of that drain batch.

Also bumps the opentelemetry extra's floor from >=1.11.1 to >=1.12.0 for both opentelemetry-api and opentelemetry-sdk — this is required, not a preference: the public opentelemetry.metrics module does not exist at 1.11.1 (confirmed by direct import against the wheel), so the feature can't be built without it. Kept the sdk floor in lockstep with the api floor, matching how they're already pinned together.

uv.lock is left untouched. Regenerating it locally pulled in ~1600 unrelated lines from pre-existing lockfile drift unrelated to this change; CI resolves fresh via uv sync --all-extras with no --frozen/--locked gate on PR checks, so this shouldn't block anything.

Why?

Resolves the scope @Sushisource set in #1049: rather than wiring MetricMeter across a multiprocessing queue (the issue's original ask), drain MetricBuffer into a real OTel MeterProvider directly, so users get full access to standard OTel features (views, resource, exemplars/tracing-integration) instead of a second, narrower metrics abstraction.

Validation

  • pytest tests/contrib/opentelemetry/test_metrics_exporter.py — 7/7 passing, against a real locally-built native bridge extension (not skipped/mocked): counter delta accumulation across multiple drains, gauge latest-value-wins semantics, histogram count/sum, attribute passthrough, description/unit passthrough, the buffer-not-attached RuntimeError, full async with start/shutdown lifecycle (background task actually completes, doesn't leak), and error-in-one-update-doesn't-block-the-rest isolation.
  • ruff check / ruff format --check — clean on all changed/new files.
  • mypy / pyright — 0 errors on all changed/new files.

AI assistance disclosure

Claude Code assisted with implementation: designed the class after exploring MetricBuffer's API and this repo's existing contrib/opentelemetry and Worker lifecycle conventions, verified the 1.11.1 version-floor issue by directly inspecting the wheel rather than assuming, and installed Rust/protobuf to build the native bridge extension locally so the test suite could actually run rather than being left unverified. I reviewed the design and the tradeoffs above (asyncio-task lifecycle over threading, observable-gauge-plus-lock over a synchronous gauge API, fail-fast vs. log-and-continue error handling) and can defend them in review.

Addresses the MetricBuffer/OpenTelemetry direction discussed in #1049. Note: this exports metrics via runtime.metric_meter in the parent process; it does not yet include a dedicated test proving custom metrics emitted from multiprocess activities (the issue's original ask) flow through end-to-end, so I'm not claiming this fully closes the issue.

@harsh543
harsh543 requested a review from a team as a code owner July 30, 2026 21:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9b56ac441

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread temporalio/contrib/opentelemetry/_metrics_exporter.py Outdated
Comment thread pyproject.toml
Comment thread temporalio/contrib/opentelemetry/__init__.py
RespectTH123

This comment was marked as spam.

@tconley1428

Copy link
Copy Markdown
Contributor

There's been no response to the automated review.

@harsh543

Copy link
Copy Markdown
Author

@tconley1428 Apologies for the silence — I missed the automated review notification and let this sit for two weeks after you closed it. Really appreciate you flagging that, especially since you also reviewed #1283 for me.

I've now addressed all three P1 findings from the automated review:

  • Shutdown race: run() was clearing the shutdown-request event at startup, which could erase a shutdown() call made before the background task got a chance to run — causing async with to hang forever on immediate enter/exit. Fixed by only clearing it after the loop exits, and added a regression test that reproduces the hang and fails within a timeout if this regresses.
  • lambda-worker-otel dependency floor: raised to match the main opentelemetry extra's >=1.12.0 floor, since 1.11.1 lacks the public opentelemetry.metrics module this now unconditionally imports.
  • Changelog: added an entry for the new MetricsExporter under Unreleased/Added.

I also softened the PR description's Fixes #1049 — this exports metrics via runtime.metric_meter in the parent process, but doesn't yet have a dedicated test proving custom metrics from multiprocess activities (the issue's original ask) flow through end-to-end, so I didn't want to overclaim.

All tests/ruff/mypy/pyright pass locally.

Would you be willing to reopen this for another look?

@tconley1428 tconley1428 reopened this Aug 17, 2026
Resolves temporalio#1049 as scoped by @Sushisource: drain a MetricBuffer into a
real OTel MeterProvider (views, resources, exemplars all come for
free) rather than wiring MetricMeter across a multiprocessing queue.

MetricsExporter polls MetricBuffer.retrieve_updates() on a fixed
interval and maps buffered updates onto the OTel metrics API: counters
via Counter.add() (buffered counter values are deltas), histograms via
Histogram.record(), and gauges via create_observable_gauge() backed by
a lock-protected last-value cache (OTel invokes gauge callbacks from
its own export thread, concurrently with the drain loop). Instrument
and attribute objects are cached keyed by id(), exploiting the
identity-stability guarantee BufferedMetric/attributes already
document. Lifecycle (run/shutdown/async context manager) mirrors
Worker's existing asyncio.Event-based shutdown pattern rather than
using threads, since that's the only such pattern already in this
codebase.

Bumps the opentelemetry extra's floor from >=1.11.1 to >=1.12.0 for
both api and sdk -- the public opentelemetry.metrics module simply
doesn't exist at 1.11.1, confirmed by direct import against the wheel.
uv.lock is left untouched; CI resolves it fresh via `uv sync
--all-extras` with no --frozen/--locked gate on PR checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@harsh543
harsh543 force-pushed the feat/otel-metrics-exporter-1049 branch from 31800d3 to 07c31dc Compare August 18, 2026 17:11
@harsh543

harsh543 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Update: the previous CI failures across every build-lint-test matrix leg and test-latest-deps were all the same root cause — poe lint (pyright/basedpyright) failing on reportUnusedVariable/reportUnusedParameter in the new OTel metrics exporter code:

  • tests/contrib/opentelemetry/test_metrics_exporter.py: several tests unpacked the full 5-tuple from _make_runtime_and_exporter() but only used some of the fields (e.g. buffer was bound but never read).
  • temporalio/contrib/opentelemetry/_metrics_exporter.py: the observable-gauge callback's options parameter was unused.

Fixed by renaming the unused bindings to _/_options. While in there I also caught a pydocstyle D412 violation (blank line after the Example: header) that CI's lint sequence never reached because it aborts on the first failing sub-task — fixed to match the Example:: + indented-block convention used elsewhere in the repo (e.g. temporalio/contrib/aws/lambda_worker/otel.py). Verified ruff, pyright, basedpyright, and pydocstyle all pass clean on the touched files, then rebased onto latest main and force-pushed.

After that push, test-latest-deps and 6/8 build-lint-test legs are green. The remaining 4 (3.10 ubuntu-arm, 3.10 windows-latest, 3.14 macos-arm, 3.14 windows-latest) look unrelated to this PR:

  • 3.10 ubuntu-arm failed on test_continue_as_new_with_ramping_version (temporalio.service.RPCError: operation was canceled)
  • 3.14 macos-arm failed on test_workflow_custom_metrics (a timing-sensitive Prometheus-scrape assertion) — different subsystem, no relation to this PR's MetricBuffer-based exporter
  • Both Windows legs simply hit the 15-minute step timeout with no test failure logged

Neither failing test lives in a file this PR touches, and each failed on only one platform, which points to CI flakiness rather than a regression. I don't have permission to re-run jobs on this repo (403: Must have admin rights) — @tconley1428 or another maintainer, could you hit "Re-run failed jobs" on the run when you get a chance? Happy to dig further if a rerun reproduces the same failures.

@Sushisource Sushisource left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall makes sense to me, just one comment on clarifying the readme language

Comment thread README.md Outdated
Comment on lines +1996 to +1999
Metrics support also requires the `opentelemetry` extra (see above). Rather than using
`temporalio.runtime.PrometheusConfig` or `temporalio.runtime.OpenTelemetryConfig`, set a
`temporalio.runtime.MetricBuffer` as the `metrics` on `TelemetryConfig`, then drain it into a real OpenTelemetry
`MeterProvider` using `temporalio.contrib.opentelemetry.MetricsExporter`:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"Rather than using..." implies here that you should always do this rather than use one of the built in configs, but that's not the case. I would be good to re-use some of the language from the other readme about this being an option when you want to use the otel metrics pipeline, but not necessarily export to an Otel collector.

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.

Good catch, fixed — reworded to match the framing in temporalio/contrib/opentelemetry/README.md: this is presented as an option for routing metrics through the full OTel pipeline (views, resources, any OTel-compatible backend) rather than an "always prefer this" statement.

- run() cleared the shutdown-request event at startup, which could
  erase a shutdown() call made before the background task got a
  chance to run, causing async with to hang forever on immediate
  enter/exit. Only clear it after the loop exits instead.
- Raise the lambda-worker-otel extra's opentelemetry-api/-sdk floor
  to >=1.12.0 to match the main opentelemetry extra, since 1.11.1
  lacks the public opentelemetry.metrics module this now
  unconditionally imports.
- Add a changelog entry for the new MetricsExporter.
@harsh543
harsh543 force-pushed the feat/otel-metrics-exporter-1049 branch from 07c31dc to 4dd89cc Compare August 18, 2026 22:32
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.

4 participants