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
3 changes: 2 additions & 1 deletion google_adk_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ Each directory contains a complete example with its own README:
| --- | --- |
| [basic](./basic/README.md) | A single ADK agent with `TemporalModel` and one model call — no tools. The minimal end-to-end example. |
| [chatbot](./chatbot/README.md) | A multi-turn conversation over one persisted ADK session, with each turn driven by a workflow Update handler that returns the assistant's reply. |
| [tools](./tools/README.md) | A Temporal activity wrapped as an ADK tool with `activity_tool`, so tool calls run as their own activities. |
| [tools](./tools/README.md) | A Temporal activity wrapped as an ADK tool with `activity_as_tool`, so tool calls run as their own activities. |
| [agent_patterns](./agent_patterns/README.md) | A coordinator `LlmAgent` with `sub_agents`, each a `TemporalModel` with a per-agent activity summary. |
| [mcp](./mcp/README.md) | A local echo MCP toolset via `TemporalMcpToolSet` / `TemporalMcpToolSetProvider`, running MCP tools as activities. Self-contained, no Node required. |
| [streaming](./streaming/README.md) | Token streaming via `TemporalModel(streaming_topic=...)` + `WorkflowStream`, consumed by a starter with `WorkflowStreamClient`. |
| [metrics](./metrics/README.md) | Google ADK OpenTelemetry metrics exported to a local Prometheus endpoint, with replay suppression through `ReplaySafeMeterProvider`. |

To run any scenario, start its worker in one terminal and its workflow starter
in another:
Expand Down
33 changes: 33 additions & 0 deletions google_adk_agents/metrics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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 python -m google_adk_agents.metrics.run_worker
```

Then run the Workflow:

```shell
uv run 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
```

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.


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

```shell
curl -s http://127.0.0.1:9464/metrics | grep gen_ai
```

The output includes `gen_ai.invoke_agent`, `gen_ai.client.operation.duration`, and `gen_ai.client.token.usage` metrics. Prometheus replaces dots with underscores, so an exported line looks like `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, so treat these metrics as at-least-once usage signals.

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 @@

33 changes: 33 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,33 @@
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Streaming now fails explicitly, and the workflow imports MODEL_NAME.

if stream:
raise NotImplementedError(
"LocalMetricsModel does not implement streaming responses."
)
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,
),
)
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())
33 changes: 33 additions & 0 deletions google_adk_agents/metrics/run_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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],
)
Comment on lines +24 to +28

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],
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Removed max_cached_workflows from the shipped worker.

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
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 @@

38 changes: 38 additions & 0 deletions google_adk_agents/metrics/workflows/metrics_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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

from google_adk_agents.metrics.models.local_metrics_model import MODEL_NAME


@workflow.defn
class MetricsWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
agent = Agent(
name="metrics_agent",
model=TemporalModel(MODEL_NAME),
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
return final_text
2 changes: 1 addition & 1 deletion google_adk_agents/tools/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Tools — Temporal Activities as ADK Tools

A weather agent whose `get_weather` Temporal activity is wrapped as an ADK tool
with `activity_tool(...)`. The model decides to call the tool; the tool runs as
with `activity_as_tool(...)`. The model decides to call the tool; the tool runs as
its own Temporal activity — retryable and observable — rather than inline in the
workflow. This demonstrates the activity boundary for tool calls.

Expand Down
4 changes: 2 additions & 2 deletions google_adk_agents/tools/workflows/weather_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
class WeatherAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
# activity_tool runs the tool call as a real Temporal activity, so it's
# activity_as_tool runs the tool call as a real Temporal activity, so it's
# retryable and shows up in history.
weather_tool = temporalio.contrib.google_adk_agents.workflow.activity_tool(
weather_tool = temporalio.contrib.google_adk_agents.workflow.activity_as_tool(
get_weather, start_to_close_timeout=timedelta(seconds=60)
)

Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ external-storage = [
]
external-storage-redis = ["redis>=5.0.0,<8"]
gevent = ["gevent>=25.4.2 ; python_version >= '3.8'"]
google-adk = ["temporalio[google-adk] >= 1.30.0", "google-adk>=1.27.0,<2"]
google-adk = [
"temporalio[google-adk,opentelemetry]>=1.32.0,<2",
"google-adk>=2.2.0,<3",
"opentelemetry-exporter-prometheus>=0.48b0",
"prometheus-client>=0.21",
]
langsmith-tracing = [
"openai>=1.4.0",
"langsmith>=0.7.0",
Expand Down
83 changes: 83 additions & 0 deletions tests/google_adk_agents/metrics_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import uuid

import pytest
from google.adk.models import BaseLlm, LLMRegistry
from opentelemetry.sdk.metrics.export import HistogramDataPoint, InMemoryMetricReader
from temporalio.client import Client
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
from temporalio.worker import Replayer, Worker

from google_adk_agents.metrics.models.local_metrics_model import (
MODEL_NAME,
LocalMetricsModel,
)
from google_adk_agents.metrics.telemetry import install_meter_provider
from google_adk_agents.metrics.workflows.metrics_workflow import MetricsWorkflow

ADK_METER_SCOPE = "gcp.vertex.agent"


async def test_metrics_are_not_inflated_by_replay(
client: Client, monkeypatch: pytest.MonkeyPatch
) -> None:
reader = InMemoryMetricReader()
install_meter_provider(reader)

original_new_llm = LLMRegistry.new_llm

def new_llm(model: str) -> BaseLlm:
if model == MODEL_NAME:
return LocalMetricsModel(model=model)
return original_new_llm(model)

monkeypatch.setattr(LLMRegistry, "new_llm", staticmethod(new_llm))

plugin = GoogleAdkPlugin()
config = client.config()
config["plugins"] = [*config["plugins"], plugin]
client = Client(**config)
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()
history = await handle.fetch_history()

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(
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:
count = 0
for point in metric.data.data_points:
if not isinstance(point, HistogramDataPoint):
raise TypeError(f"Unexpected metric point: {type(point)}")
count += point.count
counts[metric.name] = count
return counts
Loading