Add OpenTelemetry metrics exporter built on MetricBuffer - #1701
Conversation
There was a problem hiding this comment.
💡 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".
|
There's been no response to the automated review. |
|
@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:
I also softened the PR description's All tests/ruff/mypy/pyright pass locally. Would you be willing to reopen this for another look? |
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>
31800d3 to
07c31dc
Compare
|
Update: the previous CI failures across every
Fixed by renaming the unused bindings to After that push,
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 ( |
Sushisource
left a comment
There was a problem hiding this comment.
Overall makes sense to me, just one comment on clarifying the readme language
| 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`: |
There was a problem hiding this comment.
"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.
There was a problem hiding this comment.
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.
07c31dc to
4dd89cc
Compare
What was changed
Adds
MetricsExportertotemporalio.contrib.opentelemetry: drains atemporalio.runtime.MetricBufferon a fixed interval and exports through a real OpenTelemetryMeterProvider.Counter.add()(buffered counter values are deltas).Histogram.record().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.id(), exploiting the identity-stability guaranteeBufferedMetric/BufferedMetricUpdate.attributesalready document.run()/shutdown()/async with) mirrorsWorker's existingasyncio.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()raisingRuntimeError(buffer never attached to a constructedRuntime) propagates out ofrun()rather than spinning silently. Any other per-update failure is isolated, logged, and optionally reported via anon_errorcallback, without blocking the rest of that drain batch.Also bumps the
opentelemetryextra's floor from>=1.11.1to>=1.12.0for bothopentelemetry-apiandopentelemetry-sdk— this is required, not a preference: the publicopentelemetry.metricsmodule 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.lockis left untouched. Regenerating it locally pulled in ~1600 unrelated lines from pre-existing lockfile drift unrelated to this change; CI resolves fresh viauv sync --all-extraswith no--frozen/--lockedgate on PR checks, so this shouldn't block anything.Why?
Resolves the scope @Sushisource set in #1049: rather than wiring
MetricMeteracross a multiprocessing queue (the issue's original ask), drainMetricBufferinto a real OTelMeterProviderdirectly, 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-attachedRuntimeError, fullasync withstart/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 existingcontrib/opentelemetryandWorkerlifecycle 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_meterin 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.