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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@

_ENV_FOUNDRY_AGENT_NAME = "FOUNDRY_AGENT_NAME"
_ENV_FOUNDRY_AGENT_VERSION = "FOUNDRY_AGENT_VERSION"
_ENV_FOUNDRY_AGENT_INSTANCE_CLIENT_ID = "FOUNDRY_AGENT_INSTANCE_CLIENT_ID"
_ENV_FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID = "FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID"
_ENV_FOUNDRY_AGENT_TENANT_ID = "FOUNDRY_AGENT_TENANT_ID"
_ENV_FOUNDRY_HOSTING_ENVIRONMENT = "FOUNDRY_HOSTING_ENVIRONMENT"
_ENV_FOUNDRY_PROJECT_ENDPOINT = "FOUNDRY_PROJECT_ENDPOINT"
_ENV_FOUNDRY_PROJECT_ARM_ID = "FOUNDRY_PROJECT_ARM_ID"
Expand Down Expand Up @@ -283,6 +286,46 @@ def resolve_agent_version() -> str:
return os.environ.get(_ENV_FOUNDRY_AGENT_VERSION, "")


def resolve_agent_id() -> str:
"""Resolve the agent ID.

Resolution order:
1. ``FOUNDRY_AGENT_INSTANCE_CLIENT_ID`` environment variable.
2. ``<agent_name>:<agent_version>`` if both are set.
3. ``<agent_name>`` if only name is set.
4. Empty string if nothing is available.

:return: The resolved agent ID, or an empty string if not determinable.
:rtype: str
"""
agent_id = os.environ.get(_ENV_FOUNDRY_AGENT_INSTANCE_CLIENT_ID, "")
if agent_id:
return agent_id
agent_name = os.environ.get(_ENV_FOUNDRY_AGENT_NAME, "")
agent_version = os.environ.get(_ENV_FOUNDRY_AGENT_VERSION, "")
if agent_name and agent_version:
return f"{agent_name}:{agent_version}"
return agent_name


def resolve_agent_blueprint_id() -> str:
"""Resolve the agent blueprint client ID from the ``FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID`` environment variable.

:return: The agent blueprint client ID, or an empty string if not set.
:rtype: str
"""
return os.environ.get(_ENV_FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID, "")


def resolve_agent_tenant_id() -> str:
"""Resolve the agent tenant ID from the ``FOUNDRY_AGENT_TENANT_ID`` environment variable.

:return: The agent tenant ID, or an empty string if not set.
:rtype: str
"""
return os.environ.get(_ENV_FOUNDRY_AGENT_TENANT_ID, "")


def resolve_project_id() -> str:
"""Resolve the Foundry project ARM resource ID from the ``FOUNDRY_PROJECT_ARM_ID`` environment variable.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Constants:
# Tracing
APPLICATIONINSIGHTS_CONNECTION_STRING = "APPLICATIONINSIGHTS_CONNECTION_STRING"
OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
FOUNDRY_AGENT365_TRACING_ENABLED = "FOUNDRY_AGENT365_TRACING_ENABLED"

# SSE keep-alive
SSE_KEEPALIVE_INTERVAL = "SSE_KEEPALIVE_INTERVAL"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
_ATTR_GEN_AI_SYSTEM = "gen_ai.system"
_ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
_ATTR_GEN_AI_AGENT_ID = "gen_ai.agent.id"
_ATTR_GEN_AI_AGENT_BLUEPRINT_ID = "gen_ai.agent.blueprint.id"
_ATTR_GEN_AI_AGENT_TENANT_ID = "microsoft.tenant.id"
_ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name"
_ATTR_GEN_AI_AGENT_VERSION = "gen_ai.agent.version"
_ATTR_GEN_AI_RESPONSE_ID = "gen_ai.response.id"
Expand Down Expand Up @@ -156,18 +158,16 @@ def _configure_tracing(connection_string: Optional[str] = None) -> None:
agent_name = _config.resolve_agent_name() or None
agent_version = _config.resolve_agent_version() or None
project_id = _config.resolve_project_id() or None

if agent_name and agent_version:
agent_id = f"{agent_name}:{agent_version}"
elif agent_name:
agent_id = agent_name
else:
agent_id = None
agent_id = _config.resolve_agent_id() or None
agent_blueprint_id = _config.resolve_agent_blueprint_id() or None
agent_tenant_id = _config.resolve_agent_tenant_id() or None

span_processors = [
_FoundryEnrichmentSpanProcessor(
agent_name=agent_name, agent_version=agent_version,
agent_id=agent_id, project_id=project_id,
agent_blueprint_id=agent_blueprint_id,
agent_tenant_id=agent_tenant_id,
),
]
log_record_processors = [_BaggageLogRecordProcessor()] # type: ignore[list-item]
Expand Down Expand Up @@ -217,6 +217,16 @@ def _setup_distro_export(
kwargs["enable_azure_monitor"] = True
kwargs["azure_monitor_connection_string"] = connection_string

# A365 tracing export — enabled only in hosted environments.
if (
os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT", "")
and os.environ.get("FOUNDRY_AGENT365_TRACING_ENABLED", "").lower() in ("true", "1")
):
kwargs["enable_a365"] = True
kwargs["a365_use_s2s_endpoint"] = True
kwargs["a365_enable_observability_exporter"] = True
kwargs["a365_observability_scope_override"] = "api://9b975845-388f-4429-889e-eab1ef63949c/.default"

use_microsoft_opentelemetry(**kwargs)


Expand Down Expand Up @@ -460,15 +470,20 @@ class _FoundryEnrichmentSpanProcessor:

def __init__(
self,
*,
agent_name: Optional[str] = None,
agent_version: Optional[str] = None,
agent_id: Optional[str] = None,
project_id: Optional[str] = None,
agent_blueprint_id: Optional[str] = None,
agent_tenant_id: Optional[str] = None,
) -> None:
self.agent_name = agent_name
self.agent_version = agent_version
self.agent_id = agent_id
self.project_id = project_id
self.agent_blueprint_id = agent_blueprint_id
self.agent_tenant_id = agent_tenant_id

def on_start(self, span: Any, parent_context: Any = None) -> None:
if self.project_id:
Expand Down Expand Up @@ -504,6 +519,10 @@ def _on_ending(self, span: Any) -> None:
attrs[_ATTR_GEN_AI_AGENT_VERSION] = self.agent_version
if self.agent_id:
attrs[_ATTR_GEN_AI_AGENT_ID] = self.agent_id
if self.agent_blueprint_id:
attrs[_ATTR_GEN_AI_AGENT_BLUEPRINT_ID] = self.agent_blueprint_id
if self.agent_tenant_id:
attrs[_ATTR_GEN_AI_AGENT_TENANT_ID] = self.agent_tenant_id
except Exception: # pylint: disable=broad-exception-caught
logger.debug("Failed to enrich span attributes in _on_ending", exc_info=True)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing import Any, Optional

from opentelemetry import baggage as _otel_baggage, context as _otel_context
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Route
Expand Down Expand Up @@ -367,7 +368,14 @@ async def _create_invocation_endpoint(self, request: Request) -> Response:

# Propagate invocation/session IDs as W3C baggage so downstream
# services receive them automatically via the baggage header.
# Extract incoming baggage from request headers (only baggage, not traceparent)
# to preserve parent-child span relationships while inheriting caller's baggage entries.
_incoming_baggage_ctx = W3CBaggagePropagator().extract(
carrier={"baggage": request.headers.get("baggage", "")}
)
ctx = _otel_context.get_current()
for _bkey, _bval in _otel_baggage.get_all(context=_incoming_baggage_ctx).items():
ctx = _otel_baggage.set_baggage(_bkey, _bval, context=ctx)
ctx = _otel_baggage.set_baggage(
"azure.ai.agentserver.invocation_id", invocation_id, context=ctx,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"appinsights",
"ASGI",
"autouse",
"bkey",
"bval",
"caplog",
"genai",
"hypercorn",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,82 @@ def test_agent_name_only_in_span_name():
assert "solo-agent" in invoke_spans[0].name


# ---------------------------------------------------------------------------
# Incoming W3C baggage propagation
# ---------------------------------------------------------------------------

def test_incoming_baggage_merged_into_context():
"""Incoming W3C baggage header entries are merged into OTel context."""
from opentelemetry import baggage as _otel_baggage, context as _otel_context
from opentelemetry.sdk.trace import SpanProcessor

captured_baggage = {}

class BaggageCaptureProcessor(SpanProcessor):
"""Captures baggage visible when span starts."""
def on_start(self, span, parent_context=None):
ctx = parent_context or _otel_context.get_current()
captured_baggage.update(_otel_baggage.get_all(context=ctx))

# Add our capture processor to the module provider
_MODULE_PROVIDER.add_span_processor(BaggageCaptureProcessor())

server = _make_tracing_server()
client = TestClient(server)
client.post(
"/invocations",
content=b"test",
headers={"baggage": "user.id=test-user-123,custom.key=custom-value"},
)

# Incoming baggage entries should be present
assert captured_baggage.get("user.id") == "test-user-123"
assert captured_baggage.get("custom.key") == "custom-value"


def test_incoming_baggage_does_not_break_span_parenting():
"""Incoming baggage header does not break parent-child span relationships."""
server = _make_tracing_server()

# Create a traceparent to verify parenting is preserved
trace_id_hex = uuid.uuid4().hex
span_id_hex = uuid.uuid4().hex[:16]
traceparent = f"00-{trace_id_hex}-{span_id_hex}-01"

client = TestClient(server)
client.post(
"/invocations",
content=b"test",
headers={
"traceparent": traceparent,
"baggage": "user.id=test-user-456",
},
)

spans = _get_spans()
invoke_spans = [s for s in spans if "invoke_agent" in s.name]
assert len(invoke_spans) >= 1
span = invoke_spans[0]
# The span should still have the same trace ID (parent-child preserved)
actual_trace_id = format(span.context.trace_id, "032x")
assert actual_trace_id == trace_id_hex
# And the parent span ID should match the traceparent
actual_parent_id = format(span.parent.span_id, "016x")
assert actual_parent_id == span_id_hex


def test_incoming_baggage_empty_header():
"""Empty baggage header does not cause errors."""
server = _make_tracing_server()
client = TestClient(server)
resp = client.post(
"/invocations",
content=b"test",
headers={"baggage": ""},
)
assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Project endpoint attribute
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@

from opentelemetry import baggage as _otel_baggage
from opentelemetry import context as _otel_context
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse

from azure.ai.agentserver.core import ( # pylint: disable=import-error,no-name-in-module
detach_context,
end_span,
flush_spans,
set_current_span,
trace_stream,
)
from azure.ai.agentserver.responses.models._generated import (
Expand Down Expand Up @@ -416,15 +415,19 @@ def _wrap_streaming_response(
# Inner wrap: trace_stream ends the span when iteration completes.
traced = trace_stream(response.body_iterator, otel_span)

# Outer wrap: re-attach span as current context during streaming
# so child spans are correctly parented.
# Outer wrap: re-attach the full context (span + baggage) during streaming
# so child spans are correctly parented and baggage is visible to processors.
# We capture the context now (while baggage is still attached) rather than
# relying on get_current() later when the iterator actually runs.
_captured_ctx = _otel_context.get_current()

async def _iter_with_context(): # type: ignore[return]
token = set_current_span(otel_span)
token = _otel_context.attach(_captured_ctx)
try:
async for chunk in traced:
yield chunk
finally:
detach_context(token)
_otel_context.detach(token)

response.body_iterator = _iter_with_context()
return response
Expand Down Expand Up @@ -716,7 +719,16 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable=
self._safe_set_attrs(otel_span, build_create_otel_attrs(ctx, request_id=request_id, project_id=_project_id))

# Set W3C baggage per spec §7.3
# Extract incoming baggage from request headers (only baggage, not traceparent)
# to preserve parent-child span relationships while inheriting caller's baggage entries.
_incoming_baggage_ctx = W3CBaggagePropagator().extract(
carrier={"baggage": request.headers.get("baggage", "")}
)
bag_ctx = _otel_context.get_current()
# Merge incoming baggage entries (e.g. user.id) onto current context
for _bkey, _bval in _otel_baggage.get_all(context=_incoming_baggage_ctx).items():
bag_ctx = _otel_baggage.set_baggage(_bkey, _bval, context=bag_ctx)

bag_ctx = _otel_baggage.set_baggage("azure.ai.agentserver.response_id", response_id, context=bag_ctx)
bag_ctx = _otel_baggage.set_baggage(
"azure.ai.agentserver.conversation_id", ctx.conversation_id or "", context=bag_ctx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"JVBE",
"hdrs",
"myproj",
"myhost"
"myhost",
"bkey",
"bval"
],
"ignorePaths": [
"*.csv",
Expand Down
Loading