Add replay-safe OpenTelemetry meter and logger providers; warn in Google ADK plugin - #1710
Draft
DABH wants to merge 15 commits into
Draft
Add replay-safe OpenTelemetry meter and logger providers; warn in Google ADK plugin#1710DABH wants to merge 15 commits into
DABH wants to merge 15 commits into
Conversation
Google ADK and similar libraries record OpenTelemetry metrics through the process-global meter provider from code that runs workflow-side, so every workflow replay re-records them. ReplaySafeMeterProvider wraps a user-supplied MeterProvider and drops synchronous instrument recordings made from workflow code during replay, matching the first-execution-only semantics of workflow.metric_meter(). Observable instruments and non-workflow recordings pass through untouched.
At worker configuration, warn when the global OpenTelemetry meter or tracer provider is not replay-safe, pointing users at ReplaySafeMeterProvider and create_tracer_provider. Add a regression test proving 1 real execution + 3 replays leaves ADK metric instruments at their nonzero baseline with ReplaySafeMeterProvider installed, plus a control asserting the 4x inflation without it.
ReplaySafeMeterProvider unconditionally forwarded get_meter attributes (added in opentelemetry 1.26) and create_histogram explicit_bucket_boundaries_advisory (added in 1.30), raising TypeError into caller code on older APIs within the supported range. Forward them only when non-None, matching unwrapped-caller behavior.
There was a problem hiding this comment.
Pull request overview
This PR adds replay-safe OpenTelemetry metrics support for workflow-side instrumentation by introducing a ReplaySafeMeterProvider wrapper and integrating replay-safety warnings into the Google ADK plugin, to prevent metric inflation during Temporal workflow replays.
Changes:
- Added
temporalio.contrib.opentelemetry.ReplaySafeMeterProviderto drop synchronous metric recordings from workflow code during replay. - Updated
GoogleAdkPluginto warn at worker configuration time when global OTel meter/tracer providers are not replay-safe. - Added regression + unit tests, and updated READMEs and CHANGELOG to document the behavior and configuration.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/contrib/opentelemetry/test_meter_provider.py | Unit tests for the replay-safe meter provider passthrough and compatibility behavior. |
| tests/contrib/google_adk_agents/test_replay_metrics.py | Regression test demonstrating replay metric inflation without the wrapper and correctness with it. |
| tests/conftest.py | Adds a fixture to reset global OTel meter provider state across tests. |
| temporalio/contrib/opentelemetry/README.md | Documents replay-safe metrics and how to install the global wrapper. |
| temporalio/contrib/opentelemetry/_meter_provider.py | Implements ReplaySafeMeterProvider and replay-gating wrappers for sync instruments. |
| temporalio/contrib/opentelemetry/init.py | Exports ReplaySafeMeterProvider and ReplaySafeTracerProvider. |
| temporalio/contrib/google_adk_agents/README.md | Documents replay behavior for ADK telemetry and recommended replay-safe global providers. |
| temporalio/contrib/google_adk_agents/_plugin.py | Adds worker-config-time warnings for non-replay-safe global OTel providers. |
| CHANGELOG.md | Adds an entry describing the new provider and plugin warning behavior. |
Suppressed comments (1)
temporalio/contrib/google_adk_agents/_plugin.py:69
- Add
stacklevel(and ideallycategory) to this warning so it points at the user’s worker/client setup callsite instead of inside the plugin implementation. This makes the warning much easier to act on when it fires during worker configuration.
warnings.warn(
"The global OpenTelemetry TracerProvider is not replay-safe: Google ADK "
"creates spans from workflow code, so every workflow replay will "
"re-emit them. Install a replay-safe provider: "
"opentelemetry.trace.set_tracer_provider("
"temporalio.contrib.opentelemetry.create_tracer_provider())"
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Guard the private _Gauge import (added in opentelemetry-api 1.23) and import ReplaySafeMeterProvider into the package lazily so temporalio.contrib.opentelemetry stays importable for tracing-only users on opentelemetry-api < 1.12, with an actionable error on access. - Classify the global meter provider without a module-level private import; when the private proxy class cannot be imported the provider is unclassifiable and no warning is issued. - Forward the context argument to sync instrument wrappers only when set; the parameter was added in opentelemetry-api 1.28 and older instruments reject it. - Add explicit UserWarning category and stacklevel so provider warnings point at the user's Worker(...) call.
ADK emits gen_ai.* log events from workflow code through the global logger provider, so replays duplicate them like metrics and spans. ReplaySafeLoggerProvider wraps a LoggerProvider and drops Logger.emit during replay. The logs API only exists in opentelemetry-api >= 1.15, so the import is guarded like the meter provider's; emit forwards arguments verbatim since its signature changed in 1.38.
Validate the global logger provider alongside meter and tracer, and run the validation from configure_replayer too since Replayer replays are exactly where unsafe providers re-record telemetry. Warn only on providers positively identified as replay-unsafe (OTel SDK providers used directly) so custom wrappers around replay-safe providers no longer trigger false positives, and compute the warning stacklevel dynamically so attribution survives wrapping plugins. Add log-event replay regression tests mirroring the metrics ones.
DABH
force-pushed
the
google-adk-replay-safe-metrics
branch
from
August 1, 2026 09:02
62120b6 to
891b68b
Compare
DABH
marked this pull request as draft
August 3, 2026 17:00
The de facto floor of temporalio.contrib.opentelemetry is already 1.24: _tracer_provider.py imports opentelemetry.util._decorator's _agnosticcontextmanager, which was added in opentelemetry-api 1.24, so the declared 1.11.1 floor has been uninstallable in practice for this contrib regardless of the new providers. Aligning the declaration with reality lets the replay-safe providers import _Gauge (exported since 1.23) and the logs API unconditionally, removing the guarded-import machinery in the package __init__ and the subprocess-based import-absence tests. The conditional kwarg forwards stay: get_meter/get_logger attributes arrived in 1.26, synchronous-instrument context in 1.28, and create_histogram's explicit_bucket_boundaries_advisory in 1.30, all above the new floor.
Logger is an ABC whose __init__ records the instrumentation scope, so the wrapper now threads name/version/schema_url from get_logger through to the base class instead of relying on __getattr__ delegation for that state. Fixes the basedpyright reportMissingSuperCall error that failed lint. Also documents that opentelemetry._logs is the import path OpenTelemetry itself sanctions for the logs bridge API while it is pre-GA.
The plugin now warns only when the global meter or tracer provider is an instance of the public OpenTelemetry SDK provider classes; everything else (unset proxies, no-ops, unknown wrappers) stays silent, which drops the private _ProxyMeterProvider/ProxyLoggerProvider imports. The SDK logger provider is not checked because its class is only importable from the underscore namespace opentelemetry.sdk._logs while OTel logs are pre-GA. The opentelemetry.sdk import is guarded for the SDK-less install case, the warning stacklevel walk uses inspect.currentframe, and the conftest OTel global resets are documented as the isolation pattern OpenTelemetry's own test suite uses.
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.
What was changed
temporalio.contrib.opentelemetry.ReplaySafeMeterProvider, mirroring the existingReplaySafeTracerProvider: wraps a user-suppliedMeterProviderand drops synchronous instrument recordings (add()/record()/set()) made from workflow code while replaying (workflow.in_workflow() and workflow.unsafe.is_replaying()). Observable instruments and all non-workflow recordings pass through untouched.temporalio.contrib.opentelemetry.ReplaySafeLoggerProvider, the same gate for the OTel logs bridge: dropsLogger.emit()calls made from workflow code while replaying, so ADK'sgen_ai.*log events (gen_ai.choice,gen_ai.user.message, ...) are not re-emitted on every replay.opentelemetry._logsis the import path OpenTelemetry itself documents for the logs bridge API while it is pre-GA.ReplaySafeTracerProvideris now also exported fromtemporalio.contrib.opentelemetry(previously importable only from the private_tracer_providermodule), completing the replay-safe provider trio alongside the new meter and logger providers.GoogleAdkPluginnow warns at worker and replayer configuration when the global OTel meter or tracer provider is positively identified as replay-unsafe, i.e. is an instance of the public OpenTelemetry SDK provider classes (opentelemetry.sdk.metrics.MeterProvider/opentelemetry.sdk.trace.TracerProvider). Everything else — unset proxies, no-ops, unknown custom wrappers — stays silent, since a false positive is worse than a missed warning. The SDK logger provider is deliberately not checked: its class is only importable from the underscore namespaceopentelemetry.sdk._logs, so there is no public type to test against yet. Warnings carry actionable install snippets and are attributed (viastacklevel) to the user'sWorker(...)/Replayer(...)call.opentelemetryandlambda-worker-otelextras' floors are raised fromopentelemetry-api/sdk >= 1.11.1to>= 1.24. This aligns the declarations with reality:temporalio.contrib.opentelemetryalready importsopentelemetry.util._decorator._agnosticcontextmanager(added in opentelemetry-api 1.24) on main, so the declared 1.11.1 floor has been unimportable in practice for this contrib all along (install succeeds; importing the contrib fails) (andtemporalio.contrib.aws.lambda_worker.otelbuilds on it). With the honest floor,_Gauge(OTel's canonical exported name for the spec-experimental synchronous gauge, exported since 1.23) and the logs API import unconditionally — no lazy__getattr__exports or guarded imports. The remaining version accommodations are forwarding newer optional kwargs (get_meter/get_loggerattributes, 1.26; synchronous-instrumentcontext, 1.28;create_histogramexplicit_bucket_boundaries_advisory, 1.30) only when the caller sets them, and forwardingLogger.emit()arguments verbatim because its signature changed shape within the supported range (a single positionalLogRecordthrough 1.37, keyword fields from 1.38).BaseLlmwithusage_metadata) + 3 replays viaReplayer. WithReplaySafeMeterProvider, everygcp.vertex.agentinstrument stays at its nonzero baseline; withReplaySafeLoggerProvider, everygen_ai.*log event stays at its baseline; controls document the unfixed 4× inflation for both.emitsignatures) and the plugin warning path (warns on SDK providers, silent on unset, replay-safe-wrapped, and unknown custom providers,stacklevelattribution through wrapping plugins).contrib/google_adk_agentsandcontrib/opentelemetry; CHANGELOG entries.Why
google-adk records its metrics (
gen_ai.client.token.usage,gen_ai.client.operation.duration,gen_ai.invoke_agent.*,gen_ai.execute_tool.duration) and emits itsgen_ai.*log events through the process-global OTel providers, from flow code that the ADK plugin runs workflow-side (model/tool calls are activities, but the instrumentation wrapping them is not). Workflow code re-executes on every replay — cache eviction, worker restart, redeploy,max_cached_workflows=0— so each replay re-records every measurement and re-emits every log event even though the activities resolve from history. Measured: 1 real execution + 3 replays → exactly 4× on every instrument (gen_ai.client.token.usage4→16 observations, etc.) and 4 copies of every log event, while real activity executions stayed at 1. Replays also record near-zero bogus latency samples since activity results resolve instantly from history.Temporal's
workflow.metric_meter()is already replay-safe, but ADK bypasses it via the OTel global API; this provides the equivalent gate at the provider boundary, which fixes the entire supportedgoogle-adkrange without upstream changes.Recordings and emissions become first-execution-only, matching
workflow.metric_meter()semantics.Testing
uv run pytest tests/contrib/google_adk_agents tests/contrib/opentelemetry→ 60 passed, 5 skipped (pre-existing skips: API-key-gated or marked skip-in-CI).poe lint(ruff check/format, pyright, mypy, basedpyright, pydocstyle) andpoe gen-docsclean.