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
14 changes: 14 additions & 0 deletions python/google-adk/sample-agent/.gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Cloud Run source upload allowlist.
# Ignore everything, then re-include only what the container needs at build/run.
# This forces the pip buildpack (requirements.txt) and avoids uploading the
# uv.lock (stale, missing mcp), the local .venv, secrets (.env), and a365 config.
*

!*.py
!Procfile
!requirements.txt
!.python-version
!ToolingManifest.json

# Re-exclude helper/local-only python files matched by !*.py above.
_freeze.py
1 change: 1 addition & 0 deletions python/google-adk/sample-agent/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
1 change: 1 addition & 0 deletions python/google-adk/sample-agent/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: python main.py
60 changes: 56 additions & 4 deletions python/google-adk/sample-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,34 @@ All values below come from `a365.config.json` and `a365.generated.config.json` (

See [Deploy agent to GCP](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/deploy-agent-gcp) for full instructions.

Use the provided deploy script — it reads your local `.env`, applies the
production overrides the A365 observability exporter needs, and deploys via
Cloud Run source buildpacks (`Procfile` → `python main.py`):

```powershell
./deploy-cloudrun.ps1 -ProjectId <gcp-project-id> [-Region us-central1] [-ServiceName gcp-a365-agent]
```

The script deploys with **`--no-cpu-throttling`, which is required**: the
OpenTelemetry `BatchSpanProcessor` exports genAI spans on a background thread
*after* the turn returns. With default Cloud Run CPU throttling that thread
wakes on a frozen CPU and its TLS write stalls, so the gateway drops the
connection (`SSL UNEXPECTED_EOF_WHILE_READING`) and spans are silently lost.

> Cloud Run injects `PORT` and `K_SERVICE` automatically; `main.py` reads both,
> so the JWT middleware and production host binding engage with no extra config.

Build inputs are controlled by three scaffolding files in this folder:
`Procfile` (start command), `.python-version` (runtime), and `.gcloudignore`
(forces the pip/`requirements.txt` buildpack and excludes the local `.venv`,
`.env`, and a365 config from the upload).

To deploy manually instead of using the script:

```bash
# Deploy to Cloud Run
gcloud run deploy gcp-a365-agent --source . --region us-central1 --platform managed --allow-unauthenticated
gcloud run deploy gcp-a365-agent --source . --region us-central1 \
--platform managed --allow-unauthenticated --no-cpu-throttling \
--set-env-vars "ENABLE_OBSERVABILITY=true,ENABLE_A365_OBSERVABILITY_EXPORTER=true,AUTH_HANDLER_NAME=AGENTIC,..."
```

Set `a365.config.json` with your Cloud Run URL and `needDeployment: false`:
Expand All @@ -283,6 +308,33 @@ Register only the messaging endpoint (skip Azure deploy):
a365 setup blueprint --endpoint-only
```

### Production observability (A365 exporter)

When `ENABLE_A365_OBSERVABILITY_EXPORTER=true`, genAI spans are exported to the
Agent 365 backend instead of the console. Two pieces of wiring make this work:

**1. Per-turn token exchange + cache.** The A365 exporter authenticates each
span export with a Bearer token, but export happens asynchronously *after* the
turn handler returns. During each authenticated turn, `agent.py` exchanges an
observability-scoped agentic token and caches it per `(tenant, agent)` in
`token_cache.py`. `main.py` passes `observability_token_resolver` to
`configure()`, which the exporter calls to retrieve that cached token. Token
exchange is best-effort — a failure only means spans aren't exported that cycle,
it never breaks the turn.

> The observability **agent id is the app instance id (`AGENTIC_APP_ID`)**, not
> `AGENTIC_USER_ID`. The ingestion endpoint enforces `ValidateAgentIdentity`:
> the `{agentId}` in the export URL must equal the `azp`/`appid` in the agentic
> token, which is the app instance id.

**2. Span operation-name remap.** Google ADK tags its inference span with
`gen_ai.operation.name = "generate_content"` (or no operation name at all),
which A365/Maven ingestion drops — it only accepts `invoke_agent`,
`execute_tool`, `chat`, and `output_messages`. `observability_remap.py`
rewrites these inference spans to `chat` on export (via the SDK's
`register_span_enricher` hook) so model and token-usage data reaches Maven's
`InferenceCall` table. No SDK or Maven changes are required.

### Messaging endpoint reference

See [Configure messaging endpoint](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-messaging-endpoint) for all hosting options.
Expand Down Expand Up @@ -367,9 +419,9 @@ All configuration is via environment variables (`.env` for local, App Settings f
| `GOOGLE_GENAI_USE_VERTEXAI` | `FALSE` | Set `TRUE` to use Vertex AI instead of Gemini API |
| `AUTH_HANDLER_NAME` | _(empty)_ | Empty = anonymous (Playground/local), `AGENTIC` = production |
| `BEARER_TOKEN` | _(empty)_ | Token for MCP tool access. Get with `a365 develop get-token -o raw` |
| `AGENTIC_APP_ID` | — | Agent App ID from A365 portal |
| `AGENTIC_APP_ID` | — | Agent **app instance** ID. Used as the observability agent id (must match the token `azp`) |
| `AGENTIC_TENANT_ID` | — | Azure tenant ID |
| `AGENTIC_USER_ID` | — | Agent User ID from A365 portal |
| `AGENTIC_USER_ID` | — | Agent **user** ID (identity enrichment); populated after admin approves the instance |
| `PORT` | `3978` | Server port (Azure sets this to `8000` automatically) |
| `ENABLE_OBSERVABILITY` | `true` | Enable OpenTelemetry tracing |
| `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | Send traces to A365 backend (`true` for production) |
Expand Down
66 changes: 64 additions & 2 deletions python/google-adk/sample-agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,70 @@ async def invoke_agent_with_scope(
# Fall back to env vars so observability baggage is still populated.
recipient = context.activity.recipient
tenant_id = getattr(recipient, "tenant_id", None) or os.getenv("AGENTIC_TENANT_ID", "")
agent_id = getattr(recipient, "agentic_user_id", None) or os.getenv("AGENTIC_USER_ID", "")
with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build():
# The A365 observability ingestion endpoint enforces ValidateAgentIdentity:
# the {agentId} in the export URL (and every gen_ai.agent.id span attribute)
# must equal the application id (azp/appid) carried in the agentic OBO token.
# That token's azp is the agent_app_instance_id (AGENTIC_APP_ID), NOT the
# agentic_user_id — so the observability agent id must be the app instance id.
agent_id = getattr(recipient, "agentic_app_id", None) or os.getenv("AGENTIC_APP_ID", "")

# Identity enrichment so the exported spans carry the dimensions the Agent 365 /
# IDEAs activity rollup needs to attribute usage. Without these, the admin-center
# "Activity" tabs stay empty ("When people are using agents, their usage data will
# show up here") even though spans ingest successfully:
# - user.id (the invoking human) -> "active users" metric
# - microsoft.a365.agent.blueprint.id -> blueprint-level Activity tab
# - microsoft.agent.user.id / email -> agent (instance) attribution
from_prop = context.activity.from_property
user_aad_object_id = getattr(from_prop, "aad_object_id", None) or getattr(from_prop, "id", None)
user_display_name = getattr(from_prop, "name", None)
# Blueprint id (a365.generated.config.json → agentBlueprintId). This is the
# blueprint/template id, distinct from AGENTIC_APP_ID (the instance app id).
# The service-connection CLIENTID already carries the blueprint id, so we reuse
# it as the fallback and no extra env var is required for deployment.
blueprint_id = (
getattr(recipient, "agent_blueprint_id", None)
or os.getenv("AGENT_BLUEPRINT_ID")
or os.getenv("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", "")
)
agentic_user_id = getattr(recipient, "agentic_user_id", None) or os.getenv("AGENTIC_USER_ID", "")
agentic_user_email = os.getenv("AGENTIC_UPN", "")

baggage = (
BaggageBuilder()
.tenant_id(tenant_id)
.agent_id(agent_id)
.agent_blueprint_id(blueprint_id)
.agentic_user_id(agentic_user_id)
.agentic_user_email(agentic_user_email)
.user_id(user_aad_object_id)
.user_name(user_display_name)
)
with baggage.build():
# When running with an agentic auth handler (production), exchange for an
# observability-scoped token and cache it so the A365 exporter can authenticate
# its span export for this (tenant, agent). Best-effort: a failure here must
# not break the turn — it only means spans aren't exported this cycle.
if auth_handler_name and tenant_id and agent_id:
try:
from microsoft_agents_a365.runtime.environment_utils import (
get_observability_authentication_scope,
)
from token_cache import cache_agentic_token

exaau_token = await auth.exchange_token(
context,
scopes=get_observability_authentication_scope(),
auth_handler_id=auth_handler_name,
)
if exaau_token and getattr(exaau_token, "token", None):
cache_agentic_token(tenant_id, agent_id, exaau_token.token)
logger.info("Cached observability token for agent_id=%s", agent_id)
else:
logger.warning("Observability token exchange returned no token")
except Exception as e:
logger.warning("Observability token exchange failed: %s", e)

return await self.invoke_agent(message=message, auth=auth, auth_handler_name=auth_handler_name, context=context)

async def _cleanup_agent(self, agent: Agent):
Expand Down
75 changes: 75 additions & 0 deletions python/google-adk/sample-agent/deploy-cloudrun.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Deploy the Google ADK A365 sample agent to GCP Cloud Run.
#
# Reads non-secret + secret env from the local .env (gitignored), applies the
# production overrides needed for the A365 observability exporter, and deploys
# via Cloud Run source buildpacks (Procfile -> `python main.py`).
#
# Cloud Run automatically injects PORT and K_SERVICE; main.py reads both, so the
# JWT middleware + production host binding engage with no extra config.
#
# Usage:
# .\deploy-cloudrun.ps1 -ProjectId <gcp-project-id> [-Region us-central1] [-ServiceName gcp-a365-agent]

param(
[Parameter(Mandatory = $true)] [string] $ProjectId,
[string] $Region = "us-central1",
[string] $ServiceName = "gcp-a365-agent"
)

$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot

if (-not (Test-Path ".env")) { throw ".env not found in $PSScriptRoot" }

# Production overrides applied on top of .env. PORT is intentionally omitted
# (Cloud Run sets it). AUTH_HANDLER_NAME=AGENTIC turns on agentic token exchange.
$overrides = [ordered]@{
"AUTH_HANDLER_NAME" = "AGENTIC"
"ENABLE_OBSERVABILITY" = "true"
"ENABLE_A365_OBSERVABILITY_EXPORTER" = "true"
"PYTHON_ENVIRONMENT" = "production"
}

# Parse .env into an ordered map (skip comments, blanks, and PORT).
$envMap = [ordered]@{}
foreach ($line in Get-Content ".env") {
$trimmed = $line.Trim()
if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { continue }
$idx = $trimmed.IndexOf("=")
if ($idx -lt 1) { continue }
$key = $trimmed.Substring(0, $idx).Trim()
$val = $trimmed.Substring($idx + 1).Trim()
if ($key -eq "PORT") { continue }
$envMap[$key] = $val
}
foreach ($k in $overrides.Keys) { $envMap[$k] = $overrides[$k] }

# Build the env-vars string using a custom delimiter (^##^) so values containing
# commas, slashes, colons, etc. are passed verbatim to gcloud.
$pairs = @()
foreach ($k in $envMap.Keys) { $pairs += "$k=$($envMap[$k])" }
$envArg = "^##^" + ($pairs -join "##")

Write-Host "Deploying '$ServiceName' to project '$ProjectId' ($Region) with $($envMap.Count) env vars..." -ForegroundColor Cyan

# --no-cpu-throttling (CPU always allocated) is REQUIRED: the OTel BatchSpanProcessor
# exports genAI spans on a background thread AFTER the turn returns. With default CPU
# throttling, that thread wakes on a frozen CPU and its TLS read stalls -> the gateway
# drops the connection (SSL UNEXPECTED_EOF_WHILE_READING) and spans are lost.
gcloud run deploy $ServiceName `
--source . `
--project $ProjectId `
--region $Region `
--platform managed `
--allow-unauthenticated `
--no-cpu-throttling `
--set-env-vars $envArg

if ($LASTEXITCODE -ne 0) { throw "gcloud run deploy failed (exit $LASTEXITCODE)" }

$url = gcloud run services describe $ServiceName --project $ProjectId --region $Region --format "value(status.url)"
Write-Host ""
Write-Host "Deployed. Service URL: $url" -ForegroundColor Green
Write-Host "Messaging endpoint: $url/api/messages" -ForegroundColor Green
Write-Host ""
Write-Host "Next: set messagingEndpoint in a365.config.json to the above, then run 'a365 setup all'." -ForegroundColor Yellow
12 changes: 12 additions & 0 deletions python/google-adk/sample-agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,22 @@ def main():
# ENABLE_A365_OBSERVABILITY_EXPORTER=true sends traces to the A365 backend;
# false falls back to the console exporter (expected in local/dev).
if os.getenv("ENABLE_OBSERVABILITY", "true").lower() == "true":
# token_resolver supplies the Bearer token the A365 exporter uses to POST
# spans. It reads the agentic token cached during each authenticated turn
# (see agent.py invoke_agent_with_scope). When the A365 exporter is disabled
# (console exporter), the resolver is simply never called.
from token_cache import observability_token_resolver
configure(
service_name=os.getenv("OBSERVABILITY_SERVICE_NAME", "GoogleADKSampleAgent"),
service_namespace=os.getenv("OBSERVABILITY_SERVICE_NAMESPACE", "GoogleADKTesting"),
token_resolver=observability_token_resolver,
)
# Google ADK tags the LLM span as gen_ai.operation.name="generate_content",
# which A365/Maven ingestion drops (it only accepts invoke_agent, execute_tool,
# chat, output_messages). Remap generate_content -> chat on export so the
# inference span (model + token usage) reaches Maven's InferenceCall table.
from observability_remap import register_generate_content_remap
register_generate_content_remap()
logger.info(
"Observability configured (service=%s, a365_exporter=%s)",
os.getenv("OBSERVABILITY_SERVICE_NAME", "GoogleADKSampleAgent"),
Expand Down
Loading
Loading