Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions google_adk_agents/metrics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Google ADK replay-safe metrics

This sample exports Google ADK's OpenTelemetry metrics to a local Prometheus endpoint while preventing Workflow replay from recording the same observations again. The default scripted model is deterministic and makes no network model calls, so no API key is needed.

Start a local Temporal development server:

```shell
temporal server start-dev
```

In another terminal, start the worker from the repository root:

```shell
uv run --project google_adk_agents/metrics python -m google_adk_agents.metrics.run_worker
```

Then run the Workflow:

```shell
uv run --project google_adk_agents/metrics python -m google_adk_agents.metrics.run_metrics_workflow
```
Comment on lines +13 to +21

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.

Companion to the pyproject.toml:8 comment — once the nested project is gone, --project has nothing to point at, and the suite README already documents the house form at :48-53.

Suggested change
```shell
uv run --project google_adk_agents/metrics python -m google_adk_agents.metrics.run_worker
```
Then run the Workflow:
```shell
uv run --project google_adk_agents/metrics python -m google_adk_agents.metrics.run_metrics_workflow
```
```shell
uv run python -m google_adk_agents.metrics.run_worker
```
Then run the Workflow:
```shell
uv run python -m google_adk_agents.metrics.run_metrics_workflow
```


The starter prints `Replay-safe metrics are ready.` Inspect the metrics exposed by the worker:

```shell
curl http://127.0.0.1:9464/metrics | rg 'gen_ai'

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.

rg is the only ripgrep invocation in any markdown file in the repo (grep -rln '| rg ' --include='*.md' . gives one hit) and it isn't a documented prerequisite.

Suggested change
curl http://127.0.0.1:9464/metrics | rg 'gen_ai'
curl -s http://127.0.0.1:9464/metrics | grep gen_ai

```

The output includes `gen_ai.invoke_agent` metrics, `gen_ai.client.operation.duration`, and `gen_ai.client.token.usage` from instrumentation scope `gcp.vertex.agent`. The worker sets `max_cached_workflows=0`, forcing replay between Workflow tasks. `ReplaySafeMeterProvider` drops observations made while replaying, so replay does not multiply the recorded counts.

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.

Two corrections here.

The no-inflation sentence is true — I verified it — but it's the README's only statement about recording semantics and it omits the half the SDK spells out at _meter_provider.py:221-223: recordings are first-execution-only, and a Workflow task retry re-executes live and can record again. The guard is in_workflow() and is_replaying_history_events(), and a failed WFT appends WorkflowTaskFailed without advancing the replay boundary — which is exactly how a058b20 produced assert 2 == 1 on macOS after a TMPRL1101 deadlock. For a sample whose entire subject is metric accuracy, a reader will otherwise take these as exactly-once.

Second, the instrumentation scope isn't observable in the output — the Prometheus exporter emits no otel_scope_* labels, and the real line is the munged gen_ai_invoke_agent_duration_seconds_count{gen_ai_agent_name="metrics_agent"} 1.0. And the max_cached_workflows sentence goes away with the run_worker.py:28 change.

Suggested change
The output includes `gen_ai.invoke_agent` metrics, `gen_ai.client.operation.duration`, and `gen_ai.client.token.usage` from instrumentation scope `gcp.vertex.agent`. The worker sets `max_cached_workflows=0`, forcing replay between Workflow tasks. `ReplaySafeMeterProvider` drops observations made while replaying, so replay does not multiply the recorded counts.
The output includes `gen_ai.invoke_agent` metrics, `gen_ai.client.operation.duration`, and `gen_ai.client.token.usage`, exported with dots munged to underscores — for example `gen_ai_invoke_agent_duration_seconds_count{gen_ai_agent_name="metrics_agent"} 1.0`. `ReplaySafeMeterProvider` drops observations made while replaying, so replay does not multiply the recorded counts.
Recordings are first-execution-only rather than exactly-once: replay is suppressed, but a Workflow task retry re-executes live and can record again. Treat these counters as at-least-once — aggregate with rates and percentiles rather than relying on exact counts.


OpenTelemetry's global meter provider can be installed only once per process. `run_worker.py` installs the replay-safe provider before importing Google ADK or the Workflow. Applications embedding this setup must likewise make it the first and only global meter provider installation in that process.
1 change: 1 addition & 0 deletions google_adk_agents/metrics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions google_adk_agents/metrics/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

29 changes: 29 additions & 0 deletions google_adk_agents/metrics/models/local_metrics_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from collections.abc import AsyncGenerator

from google.adk.models import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types

MODEL_NAME = "local-metrics-model"


class LocalMetricsModel(BaseLlm):
@classmethod
def supported_models(cls) -> list[str]:
return [MODEL_NAME]

async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
Comment on lines +16 to +18

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.

stream=True silently yields a single non-partial response rather than streaming, so a reader who points the streaming scenario at this model gets quietly wrong behavior instead of an error.

Suggested change
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
if stream:
raise NotImplementedError(
"LocalMetricsModel does not implement streaming responses."
)

Minor, separately: metrics_workflow.py:17 retypes "local-metrics-model" instead of importing MODEL_NAME from :8 here.

yield LlmResponse(
content=types.Content(
role="model",
parts=[types.Part(text="Replay-safe metrics are ready.")],
),
usage_metadata=types.GenerateContentResponseUsageMetadata(
prompt_token_count=8,
candidates_token_count=5,
total_token_count=13,
),
)
25 changes: 25 additions & 0 deletions google_adk_agents/metrics/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[project]
name = "google-adk-agents-metrics-sample"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"google-adk>=2.2.0,<3",
"opentelemetry-exporter-prometheus>=0.48b0",
"temporalio[google-adk,opentelemetry] @ git+https://github.com/temporalio/sdk-python.git@78159d7735b2defc6493669f6c14a0fae6eab985",

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.

Two problems, and I think the whole nested file should go.

The pin's premise doesn't hold: the PR description says it stands "until that API is available in a release", but ReplaySafeMeterProvider shipped in 1.32.0, tagged 2026-08-24 21:33Z — the day before this PR opened — and 78159d7 is an ancestor of that tag. Root already carries temporalio>=1.32.0,<2 on main. A git reference also has to be built by maturin from source, so the README's documented command quietly requires a Rust toolchain and protoc; I measured about 15 minutes from scratch.

More importantly the nested project orphans the sample from CI. Root has no [tool.uv.workspace] (grep -c 'tool.uv.workspace' pyproject.toml is 0 on both the branch base and origin/main), so this directory isn't a workspace member, and CI installs root groups only (.github/workflows/ci.yml:45,48-49). prometheus appears zero times in uv.lock and zero times in the 20,722-line CI log — meaning run_worker.py:3 and :12 can't resolve there, no test imports the module, and anyone following google_adk_agents/README.md:27 (uv sync --group google-adk) gets ModuleNotFoundError.

lambda_worker/ isn't a precedent for this — it's a deployment artifact with a tracked uv.lock, its own [build-system], and explicit carve-outs at root pyproject.toml:92,96,162,167. grep -n metrics pyproject.toml returns nothing, so metrics/ is carved out of nothing and is simultaneously its own project and a member of the root one.

Deleting this file and folding the genuinely new requirements into the root google-adk group, then uv lock, also resolves the undeclared prometheus_client, the nested ruff island and the dead [tool.pytest.ini_options]:

google-adk = [
    "temporalio[google-adk,opentelemetry] >= 1.32.0",
    "google-adk>=2.2.0,<3",
    "opentelemetry-exporter-prometheus>=0.48b0",
    "prometheus-client>=0.21",
]

Worth checking the google-adk>=2.2.0,<3 bump against the root's current >=1.27.0,<2 before landing it — that's a real version move for the other six scenarios, not just this one.

]

[dependency-groups]
dev = [
"pytest>=7.1.2,<9",
"pytest-asyncio>=0.23,<2",
"ruff>=0.5.0,<0.6",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"

[tool.ruff]
target-version = "py310"

[tool.uv]
package = false
21 changes: 21 additions & 0 deletions google_adk_agents/metrics/run_metrics_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import asyncio

from temporalio.client import Client
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin

from google_adk_agents.metrics.workflows.metrics_workflow import MetricsWorkflow


async def main() -> None:
client = await Client.connect("localhost:7233", plugins=[GoogleAdkPlugin()])
result = await client.execute_workflow(
MetricsWorkflow.run,
"Explain replay-safe metrics.",
id="google-adk-agents-metrics-workflow-id",
task_queue="google-adk-agents-metrics",
)
print(f"Result: {result}")


if __name__ == "__main__":
asyncio.run(main())
34 changes: 34 additions & 0 deletions google_adk_agents/metrics/run_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import asyncio

from opentelemetry.exporter.prometheus import PrometheusMetricReader

from google_adk_agents.metrics.telemetry import install_meter_provider


async def main() -> None:
install_meter_provider(PrometheusMetricReader())

from google.adk.models import LLMRegistry
from prometheus_client import start_http_server
from temporalio.client import Client
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
from temporalio.worker import Worker

from google_adk_agents.metrics.models.local_metrics_model import LocalMetricsModel
from google_adk_agents.metrics.workflows.metrics_workflow import MetricsWorkflow

LLMRegistry.register(LocalMetricsModel)
start_http_server(port=9464, addr="127.0.0.1")
plugin = GoogleAdkPlugin()
client = await Client.connect("localhost:7233", plugins=[plugin])
worker = Worker(
client,
task_queue="google-adk-agents-metrics",
workflows=[MetricsWorkflow],
max_cached_workflows=0,
)
Comment on lines +24 to +29

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.

max_cached_workflows=0 in a shipped worker under-reports the two duration metrics the README advertises. ADK sets start_time = time.monotonic() inside the Workflow thread and records in a finally:, so evicting after every Workflow task re-establishes start_time during in-memory replay and excludes the invoke_model Activity round trip. Measured in two isolated processes against a real dev server, workflow file byte-identical to this branch, with a 0.5s model sleep:

gen_ai.invoke_agent.duration gen_ai.client.operation.duration
default cache 0.5378s 0.5202s
max_cached_workflows=0 0.00282s 0.00217s

191x and 240x under-report, in the exact configuration a reader copies. It's also the only non-test worker in samples-python that disables the cache — grep -rn max_cached_workflows --include='*.py' . gives 17 hits, 15 of them under tests/, and all six sibling run_worker.py use the default.

The head commit's Replayer already proves replay-safety offline, so nothing is lost by dropping it (and the matching sentence at README.md:29). If you still want the forced-replay demo, keep it in the test.

Suggested change
worker = Worker(
client,
task_queue="google-adk-agents-metrics",
workflows=[MetricsWorkflow],
max_cached_workflows=0,
)
worker = Worker(
client,
task_queue="google-adk-agents-metrics",
workflows=[MetricsWorkflow],
)

await worker.run()


if __name__ == "__main__":
asyncio.run(main())
12 changes: 12 additions & 0 deletions google_adk_agents/metrics/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import opentelemetry.metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import MetricReader
from temporalio.contrib.opentelemetry import ReplaySafeMeterProvider


def install_meter_provider(reader: MetricReader) -> ReplaySafeMeterProvider:
provider = ReplaySafeMeterProvider(MeterProvider(metric_readers=[reader]))
opentelemetry.metrics.set_meter_provider(provider)
if opentelemetry.metrics.get_meter_provider() is not provider:
raise RuntimeError("The global OpenTelemetry meter provider is already set")
return provider
68 changes: 68 additions & 0 deletions google_adk_agents/metrics/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import uuid

from opentelemetry.sdk.metrics.export import InMemoryMetricReader

from google_adk_agents.metrics.telemetry import install_meter_provider

ADK_METER_SCOPE = "gcp.vertex.agent"


async def test_metrics_are_not_inflated_by_replay() -> None:

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.

This is the only real test in the repo outside tests/find . \( -name 'test_*.py' -o -name '*_test.py' \) | grep -v '^./tests/' returns two paths and the other (polling/test_service.py) has no test functions, it just matches pytest's glob. All six ADK siblings live at tests/google_adk_agents/<scenario>_test.py with the signature async def test_basic(client: Client, monkeypatch: pytest.MonkeyPatch).

The cost isn't just tidiness. tests/conftest.py doesn't apply out here, so this can't use the session-scoped env/client fixtures at :40-57, it ignores --workflow-environment (:18-23) so both CI passes run an identical path and each start a redundant server, and it hand-rolls the environment whose premature teardown is the failure at :50. It also collects first among the ADK tests purely because it sorts before tests/ — in the a058b20 macOS log it starts at 19:35:18.26 against 19:35:30.83+ for the siblings, so it alone paid ADK's cold in-workflow import anthropic (google/adk/flows/llm_flows/contents.py:62), which is what blew the 2s deadlock budget on 3/3 macOS runners.

Moving it to tests/google_adk_agents/metrics_test.py taking client: Client, dropping the inline WorkflowEnvironment, and registering LocalMetricsModel through monkeypatch the way tests/openai_agents/_mock_model.py:59 does would fix the blocker and the macOS flakiness together.

reader = InMemoryMetricReader()
install_meter_provider(reader)

from google.adk.models import LLMRegistry
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Replayer, Worker

from google_adk_agents.metrics.models.local_metrics_model import LocalMetricsModel
from google_adk_agents.metrics.workflows.metrics_workflow import MetricsWorkflow

LLMRegistry.register(LocalMetricsModel)
async with await WorkflowEnvironment.start_time_skipping() as environment:
plugin = GoogleAdkPlugin()
config = environment.client.config()
config["plugins"] = [*config["plugins"], plugin]
client = type(environment.client)(**config)
Comment on lines +23 to +27

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.

start_time_skipping takes plugins directly (temporalio/testing/_workflow.py:241), so the client rebuild isn't needed. Also type(environment.client)(**config) where all six siblings just write Client(**config).

Suggested change
async with await WorkflowEnvironment.start_time_skipping() as environment:
plugin = GoogleAdkPlugin()
config = environment.client.config()
config["plugins"] = [*config["plugins"], plugin]
client = type(environment.client)(**config)
async with await WorkflowEnvironment.start_time_skipping(
plugins=[GoogleAdkPlugin()]
) as environment:
client = environment.client
task_queue = f"google-adk-agents-metrics-{uuid.uuid4()}"

If you take this, plugin is no longer in scope for the Replayer(...) call at :49 — construct it once above the async with and pass the same instance to both.

task_queue = f"google-adk-agents-metrics-{uuid.uuid4()}"
async with Worker(
client,
task_queue=task_queue,
workflows=[MetricsWorkflow],
):
handle = await client.start_workflow(
MetricsWorkflow.run,
"Explain replay-safe metrics.",
id=f"google-adk-agents-metrics-{uuid.uuid4()}",
task_queue=task_queue,
)
result = await handle.result()

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.

This is why CI is red on all eight jobs. The async with await WorkflowEnvironment.start_time_skipping() opened at :23 exits at :41, which shuts the ephemeral server down, and handle.fetch_history() at :50 then RPCs a dead address — RPCError: tcp connect error after nine gRPC retries against 127.0.0.1:43563 in job 98639038931. Deterministic on every platform, not a macOS timing thing this time.

Fetch the history while the server is still up. I applied exactly this and the test goes green in 0.85s:

Suggested change
result = await handle.result()
result = await handle.result()
history = await handle.fetch_history()

Upstream does the same — sdk-python/tests/contrib/google_adk_agents/test_replay_metrics.py:296 fetches history as the last statement inside async with Worker(...). Note this disappears on its own if you take the test-placement suggestion at :10, since the session-scoped env fixture outlives the test.


assert result == "Replay-safe metrics are ready."
counts_before_replay = metric_counts(reader)
assert counts_before_replay["gen_ai.invoke_agent.duration"] > 0
assert counts_before_replay["gen_ai.invoke_agent.inference_calls"] > 0
assert counts_before_replay["gen_ai.client.operation.duration"] > 0
assert counts_before_replay["gen_ai.client.token.usage"] > 0

await Replayer(workflows=[MetricsWorkflow], plugins=[plugin]).replay_workflow(
await handle.fetch_history()

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.

Companion to the :40 suggestion — use the history fetched inside the environment block.

Suggested change
await handle.fetch_history()
history

)

assert metric_counts(reader) == counts_before_replay


def metric_counts(reader: InMemoryMetricReader) -> dict[str, int]:
counts: dict[str, int] = {}
data = reader.get_metrics_data()
if data is not None:
for resource_metrics in data.resource_metrics:
for scope_metrics in resource_metrics.scope_metrics:
if scope_metrics.scope.name != ADK_METER_SCOPE:
continue
for metric in scope_metrics.metrics:
counts[metric.name] = sum(
getattr(point, "count", 1) for point in metric.data.data_points

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.

getattr(point, "count", 1) reads as 1 per attribute set for a NumberDataPoint, which has value and no count — so a Counter that had doubled would still compare equal. Dead branch today since every ADK instrument here is a histogram, but it's silently defeating the assertion this test exists for. Better to fail loudly on anything unexpected:

Suggested change
getattr(point, "count", 1) for point in metric.data.data_points
point.count for point in metric.data.data_points

)
return counts
1 change: 1 addition & 0 deletions google_adk_agents/metrics/workflows/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

39 changes: 39 additions & 0 deletions google_adk_agents/metrics/workflows/metrics_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from datetime import timedelta

from google.adk import Agent
from google.adk.runners import InMemoryRunner
from google.adk.utils.context_utils import Aclosing
from google.genai import types
from temporalio import workflow
from temporalio.contrib.google_adk_agents import TemporalModel


@workflow.defn
class MetricsWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
agent = Agent(
name="metrics_agent",
model=TemporalModel("local-metrics-model"),
instruction="Answer the user briefly.",
)
runner = InMemoryRunner(agent=agent, app_name="metrics_app")
session = await runner.session_service.create_session(
app_name="metrics_app", user_id="sample-user"
)

final_text = ""
async with Aclosing(
runner.run_async(
user_id="sample-user",
session_id=session.id,
new_message=types.Content(role="user", parts=[types.Part(text=prompt)]),
)
) as events:
async for event in events:
if event.content and event.content.parts:
for part in event.content.parts:
if part.text:
final_text = part.text
await workflow.sleep(timedelta(milliseconds=1))

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.

Undocumented scaffolding — no comment in a file with no comments, no README mention, no sibling precedent; the rationale ("a post-metrics replay boundary so duplicate observations are detectable") lives only in the PR description. At head it changes nothing measurable either way (6/6), and it only ever mattered under the max_cached_workflows=0 that's being removed.

Suggested change
await workflow.sleep(timedelta(milliseconds=1))
return final_text

timedelta on :1 becomes unused with it. Note root ruff runs only --select I, so an orphan import won't be flagged.

return final_text
Loading