Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions python/google-adk/sample-agent/_freeze.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import importlib.metadata as m
Comment thread
evanmitchellgithub marked this conversation as resolved.
Outdated

skip = {"sample-google-adk", "pip", "setuptools", "wheel"}
# Windows-only packages present in the local venv that have no Linux build.
skip |= {"pywin32", "pywin32-ctypes", "pypiwin32", "pywinpty", "winsdk", "windows-curses"}
lines = []
for d in m.distributions():
name = d.metadata["Name"]
if not name or name.lower() in skip:
continue
lines.append(f"{name}=={d.version}")
lines = sorted(set(lines), key=str.lower)
with open("requirements.txt", "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print("count", len(lines))
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
128 changes: 128 additions & 0 deletions python/google-adk/sample-agent/observability_remap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""
Span operation-name remap for the Google ADK sample agent.

Google ADK auto-instrumentation tags the LLM call with
``gen_ai.operation.name = "generate_content"`` (the Google GenAI semantic
convention). Microsoft Agent 365 / Maven ingestion only accepts four
operation names — ``invoke_agent``, ``execute_tool``, ``chat`` and
``output_messages`` — and drops every other span before fan-out. As a
result the ADK inference span (model, token usage, finish reason) never
reaches Maven.

This module rewrites ``generate_content`` -> ``chat`` on export using the
A365 observability SDK's public enricher hook (``register_span_enricher``),
so the inference span maps onto Maven's InferenceCall table. The original
span is never mutated; an :class:`EnrichedReadableSpan` overlay is returned
with the single attribute overridden.

No changes to the A365 SDK or to Maven are required.
"""

import logging

from opentelemetry.sdk.trace import ReadableSpan

from microsoft_agents_a365.observability.core.constants import (
CHAT_OPERATION_NAME,
EXECUTE_TOOL_OPERATION_NAME,
GEN_AI_OPERATION_NAME_KEY,
INVOKE_AGENT_OPERATION_NAME,
OUTPUT_MESSAGES_OPERATION_NAME,
)
from microsoft_agents_a365.observability.core.exporters.enriched_span import (
EnrichedReadableSpan,
)
from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import (
get_span_enricher,
register_span_enricher,
unregister_span_enricher,
)

logger = logging.getLogger(__name__)

# The Google GenAI semantic-convention operation name (emitted only when the
# optional ``opentelemetry-instrumentation-google-genai`` package is installed).
_SOURCE_OPERATION_NAME = "generate_content"

# Attribute set by ADK's ``trace_call_llm`` on the inference span. ADK does NOT
# set ``gen_ai.operation.name`` on this span, so without a remap it is dropped by
# the Agent 365 exporter (which only keeps invoke_agent/execute_tool/chat/
# output_messages). Presence of this attribute identifies an inference call.
_GEN_AI_REQUEST_MODEL_KEY = "gen_ai.request.model"

# Operation names the Agent 365 exporter already considers eligible. A span that
# already carries one of these must never be relabelled.
_RECOGNIZED_OPERATION_NAMES = frozenset(
{
INVOKE_AGENT_OPERATION_NAME,
EXECUTE_TOOL_OPERATION_NAME,
OUTPUT_MESSAGES_OPERATION_NAME,
CHAT_OPERATION_NAME,
}
)


def _remap_generate_content_to_chat(span: ReadableSpan) -> ReadableSpan:
"""Map an ADK / Google GenAI inference span onto the ``chat`` operation.

Two shapes are handled:

1. ``gen_ai.operation.name == "generate_content"`` — emitted by the optional
``opentelemetry-instrumentation-google-genai`` package.
2. ADK's own ``call_llm`` span, which sets ``gen_ai.request.model`` but no
``gen_ai.operation.name`` at all. This is the default for google-adk
without the genai instrumentation package, and is the case in this
sample.

Any span that already carries a recognized operation name (invoke_agent,
execute_tool, chat, output_messages) is returned unchanged.
"""
attributes = span.attributes or {}
operation_name = attributes.get(GEN_AI_OPERATION_NAME_KEY)

# Never relabel a span that already has an eligible operation name.
if operation_name in _RECOGNIZED_OPERATION_NAMES:
return span

is_genai_generate_content = operation_name == _SOURCE_OPERATION_NAME
is_adk_inference_span = (
operation_name is None
and attributes.get(_GEN_AI_REQUEST_MODEL_KEY) is not None
)
if not (is_genai_generate_content or is_adk_inference_span):
return span

return EnrichedReadableSpan(
span,
extra_attributes={GEN_AI_OPERATION_NAME_KEY: CHAT_OPERATION_NAME},
)


def register_generate_content_remap() -> None:
"""Register the ``generate_content`` -> ``chat`` enricher with the SDK.

The SDK allows a single enricher at a time. If another enricher is already
registered (e.g. a platform instrumentor), this composes with it: the
existing enricher runs first, then the remap is applied to its result.
Safe to call once during application startup, after ``configure()``.
"""
existing = get_span_enricher()

if existing is None:
enricher = _remap_generate_content_to_chat
else:
def enricher(span: ReadableSpan) -> ReadableSpan:
return _remap_generate_content_to_chat(existing(span))

# Replace the existing single-slot enricher with the composed one.
unregister_span_enricher()

register_span_enricher(enricher)
logger.info(
"Registered span enricher: %s -> %s remap",
_SOURCE_OPERATION_NAME,
CHAT_OPERATION_NAME,
)
Loading
Loading