Skip to content
Merged
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
15 changes: 12 additions & 3 deletions docs/integrating-with-existing-opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ The SDK produces three core span kinds. Your backend should show them in this ty
| `gen_ai.operation.name` | Produced by | Typical parent | Span name (default) | Notes |
|-------------------------|---------------------------------------------------|----------------|---------------------|-------|
| `invoke_agent` | `InvokeAgentScope` (one per user turn) | (root or app) | `invoke_agent <agent_name>` when set, else `invoke_agent` | |
| (varies — see notes) | `InferenceScope` (one per LLM call) | `invoke_agent` | `<operation> <model>` | **Manual instrumentation** uses `InferenceOperationType.value` (currently `Chat` / `TextCompletion` / `GenerateContent`, capitalized). **Auto-instrumentation** (e.g. `OpenAIAgentsTraceInstrumentor`) uses lowercase per the [OTel GenAI semconv](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/) (e.g. `chat`). The two are inconsistent today. |
| `chat` / `text_completion` / `generate_content` | `InferenceScope` (one per LLM call) | `invoke_agent` | `<operation> <model>` | Manual inference scopes use canonical lowercase [OTel GenAI operation names](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/), matching auto-instrumentation for the corresponding operation. |
| `execute_tool` | `ExecuteToolScope` (one per tool invocation) | `invoke_agent` | `execute_tool <tool_name>` (always includes the tool name) | Records tool name, args, and result. |

Filter your backend by the `gen_ai.operation.name` attribute or by span name. Note that `inference` is *not* the literal attribute value — manual instrumentation produces `Chat` / `TextCompletion` / `GenerateContent` (the `InferenceOperationType.value`), while auto-instrumentation extension packages produce the lowercase OTel-spec form (e.g. `chat`). This casing discrepancy is tracked as an SDK issue.
Filter your backend by the `gen_ai.operation.name` attribute or by span name.
`InferenceScope` emits `chat`, `text_completion`, or `generate_content` according
to the selected `InferenceOperationType`, with span names such as `chat gpt-4`.
`inference` is not a literal operation value. When upgrading, update case-sensitive
filters that matched the previous `Chat`, `TextCompletion`, or `GenerateContent`
values and span-name prefixes, or compare case-insensitively.

## Verifying the integration

Expand All @@ -110,7 +115,11 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
```

Run a single turn. If you see `invoke_agent` / `Chat` (or `chat`) / `execute_tool` JSON dumps on stdout, the SDK is producing spans correctly — the issue is in your backend exporter (network, auth, sampling). If you don't see them, the integration itself is wrong; check the pitfalls below.
Run a single turn. If you see `invoke_agent`, the inference operation
(`chat`, `text_completion`, or `generate_content`), and any invoked `execute_tool`
spans as JSON dumps on stdout, the SDK is producing spans correctly - investigate
your backend exporter (network, auth, sampling). If expected spans are missing
from stdout too, check the integration pitfalls below.

## Common pitfalls

Expand Down
33 changes: 33 additions & 0 deletions libraries/microsoft-agents-a365-observability-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@ All notable changes to this package will be documented in this file.

## [Unreleased]

### Fixed

- **Agent 365 exporter's `GEN_AI_OPERATION_NAMES` allowlist now recognizes the
canonical lowercase operation names** (`chat`, `text_completion`,
`generate_content`) in addition to the existing aliases, since spans can now
legitimately report these values.
- **`AgentDetails.agent_type` and `InferenceCallDetails.responseModel`/cache
fields are appended at the end of their dataclasses** instead of being
inserted in the middle, preserving positional-argument compatibility for
existing callers.
- **Whitespace-only `model`/`responseModel` values are now normalized to
`None`** in `InferenceCallDetails.__post_init__`, preventing blank span
names/`gen_ai.request.model` attributes when a non-blank `responseModel` is
also supplied.
- **`AgentDetails.agent_type` now accepts `AgentType` enum members** — the
enum's `.value` is emitted on the span/baggage instead of the enum object,
which OpenTelemetry would otherwise silently drop.

### Breaking Changes

- **Manual inference operation names and span-name prefixes now use canonical
OpenTelemetry values** — `Chat`, `TextCompletion`, and `GenerateContent`
become `chat`, `text_completion`, and `generate_content`, respectively.
For example, `Chat gpt-4` becomes `chat gpt-4`. Text-completion and
generate-content spans that were previously filtered out are now exported
to Agent 365.
- **`InferenceCallDetails` now validates model identity during construction** —
If both `model` and `responseModel` are missing, empty, or whitespace-only,
construction raises `ValueError`. Previously these values were accepted
without validation. This happens before an `InferenceScope` is created.
- **OBS exports always use `/observabilityService`** — The `use_s2s_endpoint`
option is deprecated and ignored, even when `False`. Exports no longer select
or fall back to `/observability`. Provide an app-only OBS token independently
Expand All @@ -20,6 +48,11 @@ All notable changes to this package will be documented in this file.

### Migration

- Update case-sensitive operation-name and span-name filters, dashboards, and
alerts to the canonical values above, or compare case-insensitively.
Comment thread
biswapm marked this conversation as resolved.
Resolved
- Supply a non-blank `model` or `responseModel` when constructing
`InferenceCallDetails`; validate optional configuration before constructing
the telemetry object so missing model identity does not interrupt a request.
- Request the OBS resource `/.default` scope
(`api://9b975845-388f-4429-889e-eab1ef63949c/.default`) for the exporting
agent identity. Do not use delegated `Agent365.Observability.OtelWrite` tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,23 @@ with InvokeAgentScope.start(
Traces LLM/AI model inference calls:

```python
from microsoft_agents_a365.observability.core import InferenceScope, InferenceCallDetails, Request
from microsoft_agents_a365.observability.core import (
InferenceCallDetails,
InferenceOperationType,
InferenceScope,
Request,
ServiceEndpoint,
)

with InferenceScope.start(
request=Request(content="Hello"),
details=InferenceCallDetails(
model_name="gpt-4",
provider="openai"
operationName=InferenceOperationType.CHAT,
model="gpt-4",
providerName="openai",
responseModel="gpt-4-2025-04-14",
cacheReadInputTokens=20,
endpoint=ServiceEndpoint(hostname="models.example.com", port=443),
),
agent_details=agent_details,
) as scope:
Expand Down Expand Up @@ -276,9 +286,9 @@ class AgentDetails:
agent_auid: str | None # Agent unique identifier
agent_email: str | None # Agent email address
agent_blueprint_id: str | None
agent_type: AgentType | None
tenant_id: str | None
icon_uri: str | None
agent_type: AgentType | str | None # appended last for positional compat
```

### UserDetails
Expand Down Expand Up @@ -317,11 +327,27 @@ class SpanDetails:
```python
@dataclass
class InferenceCallDetails:
model_name: str | None
provider: str | None
# Additional inference metadata
operationName: InferenceOperationType
model: str | None
providerName: str
inputTokens: int | None
outputTokens: int | None
finishReasons: list[str] | None
thoughtProcess: str | None
endpoint: ServiceEndpoint | None
responseModel: str | None # appended last for positional compat
cacheReadInputTokens: int | None # appended last for positional compat
cacheWriteInputTokens: int | None # appended last for positional compat
```

At least one of `model` or `responseModel` must be present (whitespace-only
values are normalized to `None`). The `gen_ai.operation.name` attribute and the
span name prefix are derived from `operationName` via its canonical lowercase
form (`InferenceOperationType.CHAT` -> `chat`, `TEXT_COMPLETION` ->
`text_completion`, `GENERATE_CONTENT` -> `generate_content`), matching the
OpenTelemetry GenAI semantic conventions and the Agent365 model observation
pipeline. Endpoint ports and token counts are emitted as integers.

### ToolCallDetails

```python
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# Licensed under the MIT License.

from dataclasses import dataclass
from typing import Optional
from typing import Optional, Union

from .models.agent_type import AgentType


@dataclass
Expand Down Expand Up @@ -41,3 +43,10 @@ class AgentDetails:

agent_version: Optional[str] = None
"""Optional version of the agent (e.g., "1.0.0", "2025-05-01")."""

# NOTE: Appended after all pre-existing fields (rather than inserted in the
# middle) to preserve positional-argument compatibility for existing callers.
agent_type: Optional[Union[AgentType, str]] = None
"""Type classification for the agent. Accepts an `AgentType` enum member or
a raw string; enum members are normalized to their string `.value` when
emitted as a span attribute or baggage entry."""
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GEN_AI_RESPONSE_MODEL_KEY = "gen_ai.response.model"
GEN_AI_RESPONSE_ID_KEY = "gen_ai.response.id"
GEN_AI_AGENT_ID_KEY = "gen_ai.agent.id"
GEN_AI_AGENT_TYPE_KEY = "gen_ai.agent.type"
GEN_AI_AGENT_NAME_KEY = "gen_ai.agent.name"
GEN_AI_AGENT_DESCRIPTION_KEY = "gen_ai.agent.description"
GEN_AI_AGENT_VERSION_KEY = "gen_ai.agent.version"
Expand All @@ -47,6 +48,8 @@
GEN_AI_TOKEN_TYPE_KEY = "gen_ai.token.type"
GEN_AI_USAGE_INPUT_TOKENS_KEY = "gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS_KEY = "gen_ai.usage.output_tokens"
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY = "gen_ai.usage.cache_read.input_tokens"
GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY = "gen_ai.usage.cache_write.input_tokens"
GEN_AI_CHOICE = "gen_ai.choice"
GEN_AI_PROVIDER_NAME_KEY = "gen_ai.provider.name"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
# Operation names that identify a span as eligible for export to the Agent 365
# observability ingest service. Only spans whose gen_ai.operation.name matches
# one of these values are included; all other spans are filtered out.
#
# Inference spans now emit the canonical lowercase operation name for their
# InferenceOperationType (e.g. "chat", "text_completion", "generate_content" -
# see InferenceOperationType.canonical_name), so all three are recognized here.
# The legacy PascalCase "Chat" value is kept for back-compat with any
# previously-emitted/non-Python spans that still use the raw enum value.
GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset(
{
INVOKE_AGENT_OPERATION_NAME,
Expand All @@ -42,6 +48,9 @@
APPLY_GUARDRAIL_OPERATION_NAME,
CHAT_OPERATION_NAME,
InferenceOperationType.CHAT.value,
InferenceOperationType.CHAT.canonical_name,
InferenceOperationType.TEXT_COMPLETION.canonical_name,
InferenceOperationType.GENERATE_CONTENT.canonical_name,
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,30 @@ class InferenceCallDetails:
"""Details of an inference call for generative AI operations."""

operationName: InferenceOperationType
model: str
model: str | None
providerName: str
inputTokens: int | None = None
outputTokens: int | None = None
finishReasons: list[str] | None = None
thoughtProcess: str | None = None
endpoint: ServiceEndpoint | None = None
# NOTE: Appended after all pre-existing fields (rather than inserted in the
# middle) to preserve positional-argument compatibility for existing callers.
responseModel: str | None = None
cacheReadInputTokens: int | None = None
cacheWriteInputTokens: int | None = None

def __post_init__(self) -> None:
"""Normalize and validate required model identity.

Whitespace-only `model`/`responseModel` values are normalized to
`None` so that validation and downstream consumers (e.g.
`InferenceScope`) never treat a blank string as a present model.
"""
if self.model is not None:
self.model = self.model.strip() or None
if self.responseModel is not None:
self.responseModel = self.responseModel.strip() or None

if not self.model and not self.responseModel:
raise ValueError("At least one of model or responseModel must be provided")
Comment thread
biswapm marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ class InferenceOperationType(Enum):
CHAT = "Chat"
TEXT_COMPLETION = "TextCompletion"
GENERATE_CONTENT = "GenerateContent"

@property
def canonical_name(self) -> str:
"""The canonical lowercase operation name per the OpenTelemetry GenAI
semantic conventions (e.g. ``chat``, ``text_completion``,
``generate_content``). Used for both the span name prefix and the
``gen_ai.operation.name`` attribute.
"""
return self.name.lower()
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from typing import List

from opentelemetry.trace import SpanKind

from .agent_details import AgentDetails
from .constants import (
CHANNEL_LINK_KEY,
CHANNEL_NAME_KEY,
GEN_AI_AGENT_THOUGHT_PROCESS_KEY,
GEN_AI_CALLER_CLIENT_IP_KEY,
GEN_AI_CONVERSATION_ID_KEY,
GEN_AI_INPUT_MESSAGES_KEY,
GEN_AI_OPERATION_NAME_KEY,
GEN_AI_OUTPUT_MESSAGES_KEY,
GEN_AI_PROVIDER_NAME_KEY,
GEN_AI_REQUEST_MODEL_KEY,
GEN_AI_RESPONSE_FINISH_REASONS_KEY,
GEN_AI_RESPONSE_MODEL_KEY,
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY,
GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY,
GEN_AI_USAGE_INPUT_TOKENS_KEY,
GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
SERVER_ADDRESS_KEY,
SERVER_PORT_KEY,
USER_EMAIL_KEY,
USER_ID_KEY,
USER_NAME_KEY,
GEN_AI_CALLER_CLIENT_IP_KEY,
)
from .inference_call_details import InferenceCallDetails
from .message_utils import (
Expand Down Expand Up @@ -95,9 +96,11 @@ def __init__(
else SpanDetails(span_kind=SpanKind.CLIENT)
)

model_name = details.model or details.responseModel
operation_name = details.operationName.canonical_name
super().__init__(
operation_name=details.operationName.value,
activity_name=f"{details.operationName.value} {details.model}",
operation_name=operation_name,
activity_name=f"{operation_name} {model_name}",
agent_details=agent_details,
span_details=resolved_span_details,
)
Expand All @@ -106,8 +109,9 @@ def __init__(
self.record_input_messages(request.content)
self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id)

self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, details.operationName.value)
self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, operation_name)
self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, details.model)
Comment thread
biswapm marked this conversation as resolved.
Resolved
self.set_tag_maybe(GEN_AI_RESPONSE_MODEL_KEY, details.responseModel)
self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, details.providerName)
self.set_tag_maybe(
GEN_AI_USAGE_INPUT_TOKENS_KEY,
Expand All @@ -117,6 +121,14 @@ def __init__(
GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
details.outputTokens if details.outputTokens is not None else None,
)
self.set_tag_maybe(
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY,
details.cacheReadInputTokens,
)
self.set_tag_maybe(
GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY,
details.cacheWriteInputTokens,
)
self.set_tag_maybe(
GEN_AI_RESPONSE_FINISH_REASONS_KEY,
safe_json_dumps(details.finishReasons) if details.finishReasons else None,
Expand All @@ -127,7 +139,7 @@ def __init__(
if details.endpoint:
self.set_tag_maybe(SERVER_ADDRESS_KEY, details.endpoint.hostname)
if details.endpoint.port:
self.set_tag_maybe(SERVER_PORT_KEY, str(details.endpoint.port))
self.set_tag_maybe(SERVER_PORT_KEY, details.endpoint.port)

# Set request metadata if provided
if request.channel:
Expand Down Expand Up @@ -184,7 +196,15 @@ def record_output_tokens(self, output_tokens: int) -> None:
"""
self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, output_tokens)

def record_finish_reasons(self, finish_reasons: List[str]) -> None:
def record_cache_read_input_tokens(self, input_tokens: int) -> None:
"""Record the number of input tokens read from a cache."""
self.set_tag_maybe(GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, input_tokens)

def record_cache_write_input_tokens(self, input_tokens: int) -> None:
"""Record the number of input tokens written to a cache."""
self.set_tag_maybe(GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, input_tokens)

def record_finish_reasons(self, finish_reasons: list[str]) -> None:
"""Records the finish reasons for telemetry tracking.

Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
GEN_AI_AGENT_EMAIL_KEY,
GEN_AI_AGENT_ID_KEY,
GEN_AI_AGENT_NAME_KEY,
GEN_AI_AGENT_TYPE_KEY,
GEN_AI_AGENT_VERSION_KEY,
GEN_AI_CALLER_CLIENT_IP_KEY,
GEN_AI_CONVERSATION_ID_KEY,
Expand All @@ -31,6 +32,7 @@
USER_ID_KEY,
USER_NAME_KEY,
)
from ..models.agent_type import AgentType
from ..utils import validate_and_normalize_ip

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -97,6 +99,16 @@ def agent_id(self, value: str | None) -> "BaggageBuilder":
self._set(GEN_AI_AGENT_ID_KEY, value)
return self

def agent_type(self, value: AgentType | str | None) -> "BaggageBuilder":
"""Set the agent type baggage value.

Accepts either an `AgentType` enum member or a plain string; enum
members are normalized to their string `.value` since OTel baggage
values must be primitive strings.
"""
self._set(GEN_AI_AGENT_TYPE_KEY, getattr(value, "value", value))
return self

def agentic_user_id(self, value: str | None) -> "BaggageBuilder":
"""Set the agentic user ID baggage value.

Expand Down
Loading
Loading