Python telemetry (#223)

* initial work on telemetry

* moved tool operation const

* missing quotes

* working otel with samples

* updated readme and other assets

* added tests

* added tests

* small updates

* updated genaiattributes docs

* updated tests

* additional warning

* cleanup of tests
This commit is contained in:
Eduard van Valkenburg
2025-07-28 09:33:42 +02:00
committed by GitHub
Unverified
parent 3ee9dddfa2
commit 0ce8eb1e2f
27 changed files with 2197 additions and 153 deletions
+433 -20
View File
@@ -1,37 +1,138 @@
# Copyright (c) Microsoft. All rights reserved.
import functools
import json
import logging
import os
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Final
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence
from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeVar
try:
version_info = version("agent-framework")
except PackageNotFoundError:
version_info = "dev"
from opentelemetry import trace
from opentelemetry.trace import Span, StatusCode, get_tracer, use_span
# Note that if this environment variable does not exist, telemetry is enabled.
TELEMETRY_DISABLED_ENV_VAR = "AZURE_TELEMETRY_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
from . import __version__ as version_info
from ._logging import get_logger
from ._pydantic import AFBaseSettings
APP_INFO = (
{
"agent-framework-version": f"python/{version_info}",
}
if IS_TELEMETRY_ENABLED
else None
)
USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
if TYPE_CHECKING: # pragma: no cover
from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage]
from ._clients import ChatClientBase
from ._tools import AIFunction
from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate
TChatClientBase = TypeVar("TChatClientBase", bound="ChatClientBase")
tracer = get_tracer("agent_framework")
logger = get_logger()
__all__ = [
"AGENT_FRAMEWORK_USER_AGENT",
"APP_INFO",
"USER_AGENT_KEY",
"prepend_agent_framework_to_user_agent",
"use_telemetry",
]
# We're recording multiple events for the chat history, some of them are emitted within (hundreds of)
# nanoseconds of each other. The default timestamp resolution is not high enough to guarantee unique
# timestamps for each message. Also Azure Monitor truncates resolution to microseconds and some other
# backends truncate to milliseconds.
#
# But we need to give users a way to restore chat message order, so we're incrementing the timestamp
# by 1 microsecond for each message.
#
# This is a workaround, we'll find a generic and better solution - see
# https://github.com/open-telemetry/semantic-conventions/issues/1701
class ChatMessageListTimestampFilter(logging.Filter):
"""A filter to increment the timestamp of INFO logs by 1 microsecond."""
INDEX_KEY: ClassVar[str] = "CHAT_MESSAGE_INDEX"
def filter(self, record: logging.LogRecord) -> bool:
"""Increment the timestamp of INFO logs by 1 microsecond."""
if hasattr(record, self.INDEX_KEY):
idx = getattr(record, self.INDEX_KEY)
record.created += idx * 1e-6
return True
# Creates a tracer from the global tracer provider
logger.addFilter(ChatMessageListTimestampFilter())
class GenAIAttributes(str, Enum):
"""Enum to capture the attributes used in OpenTelemetry for Generative AI.
Based on: https://opentelemetry.io/docs/concepts/semantic-conventions/
Should always be used, with `.value` to get the string representation.
"""
OPERATION = "gen_ai.operation.name"
SYSTEM = "gen_ai.system"
ERROR_TYPE = "error.type"
MODEL = "gen_ai.request.model"
SEED = "gen_ai.request.seed"
PORT = "server.port"
ENCODING_FORMATS = "gen_ai.request.encoding_formats"
FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
MAX_TOKENS = "gen_ai.request.max_tokens"
STOP_SEQUENCES = "gen_ai.request.stop_sequences"
TEMPERATURE = "gen_ai.request.temperature"
TOP_K = "gen_ai.request.top_k"
TOP_P = "gen_ai.request.top_p"
FINISH_REASON = "gen_ai.response.finish_reason"
RESPONSE_ID = "gen_ai.response.id"
INPUT_TOKENS = "gen_ai.usage.input_tokens"
OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
TOOL_CALL_ID = "gen_ai.tool.call.id"
TOOL_DESCRIPTION = "gen_ai.tool.description"
TOOL_NAME = "gen_ai.tool.name"
ADDRESS = "server.address"
# Activity events
EVENT_NAME = "event.name"
SYSTEM_MESSAGE = "gen_ai.system.message"
USER_MESSAGE = "gen_ai.user.message"
ASSISTANT_MESSAGE = "gen_ai.assistant.message"
TOOL_MESSAGE = "gen_ai.tool.message"
CHOICE = "gen_ai.choice"
PROMPT = "gen_ai.prompt"
# Operation names
CHAT_COMPLETION_OPERATION = "chat.completions"
CHAT_STREAMING_COMPLETION_OPERATION = "chat.streaming_completions"
TOOL_EXECUTION_OPERATION = "execute_tool"
# Agent Framework specific attributes
MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name"
ROLE_EVENT_MAP = {
"system": GenAIAttributes.SYSTEM_MESSAGE.value,
"user": GenAIAttributes.USER_MESSAGE.value,
"assistant": GenAIAttributes.ASSISTANT_MESSAGE.value,
"tool": GenAIAttributes.TOOL_MESSAGE.value,
}
# Note that if this environment variable does not exist, telemetry is enabled.
TELEMETRY_DISABLED_ENV_VAR = "AZURE_TELEMETRY_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
APP_INFO = (
{
"agent-framework-version": f"python/{version_info}", # type: ignore[has-type]
}
if IS_TELEMETRY_ENABLED
else None
)
USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
def prepend_agent_framework_to_user_agent(headers: dict[str, Any]) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
@@ -50,4 +151,316 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any]) -> dict[str,
return headers
__all__ = ["AGENT_FRAMEWORK_USER_AGENT", "APP_INFO", "USER_AGENT_KEY", "prepend_agent_framework_to_user_agent"]
class ModelDiagnosticSettings(AFBaseSettings):
"""Settings for model diagnostics.
The settings are first loaded from environment variables with
the prefix 'AGENT_FRAMEWORK_GENAI_'.
If the environment variables are not found, the settings can
be loaded from a .env file with the encoding 'utf-8'.
If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the
settings are missing.
Warning:
Sensitive events should only be enabled on test and development environments.
Required settings for prefix 'AGENT_FRAMEWORK_GENAI_' are:
- enable_otel_diagnostics: bool - Enable OpenTelemetry diagnostics. Default is False.
(Env var AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS)
- enable_otel_diagnostics_sensitive: bool - Enable OpenTelemetry sensitive events. Default is False.
(Env var AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE)
"""
env_prefix: ClassVar[str] = "AGENT_FRAMEWORK_GENAI_"
enable_otel_diagnostics: bool = False
enable_otel_diagnostics_sensitive: bool = False
@property
def ENABLED(self) -> bool:
"""Check if model diagnostics are enabled.
Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled.
"""
return self.enable_otel_diagnostics or self.enable_otel_diagnostics_sensitive
@property
def SENSITIVE_EVENTS_ENABLED(self) -> bool:
"""Check if sensitive events are enabled.
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
"""
return self.enable_otel_diagnostics_sensitive
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
def start_as_current_span(
tracer: trace.Tracer,
function: "AIFunction[Any, Any]",
metadata: dict[str, Any] | None = None,
) -> "_AgnosticContextManager[Span]":
"""Starts a span for the given function using the provided tracer.
Args:
tracer: The OpenTelemetry tracer to use.
function: The function for which to start the span.
metadata: Optional metadata to include in the span attributes.
Returns:
trace.Span: The started span as a context manager.
"""
attributes = {
GenAIAttributes.OPERATION.value: GenAIAttributes.TOOL_EXECUTION_OPERATION.value,
GenAIAttributes.TOOL_NAME.value: function.name,
}
tool_call_id = metadata.get("tool_call_id", None) if metadata else None
if tool_call_id:
attributes[GenAIAttributes.TOOL_CALL_ID.value] = tool_call_id
if function.description:
attributes[GenAIAttributes.TOOL_DESCRIPTION.value] = function.description
return tracer.start_as_current_span(
f"{GenAIAttributes.TOOL_EXECUTION_OPERATION.value} {function.name}", attributes=attributes
)
def _trace_chat_get_response(
completion_func: Callable[..., Awaitable["ChatResponse"]],
) -> Callable[..., Awaitable["ChatResponse"]]:
"""Decorator to trace chat completion activities.
Args:
completion_func: The function to trace.
"""
@functools.wraps(completion_func)
async def wrap_inner_get_response(
self: "ChatClientBase",
*,
messages: MutableSequence["ChatMessage"],
chat_options: "ChatOptions",
**kwargs: Any,
) -> "ChatResponse":
if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED:
# If model diagnostics are not enabled, just return the completion
return await completion_func(
self,
messages=messages,
chat_options=chat_options,
**kwargs,
)
with use_span(
_get_chat_response_span(
GenAIAttributes.CHAT_COMPLETION_OPERATION.value,
getattr(self, "ai_model_id", chat_options.ai_model_id or "unknown"),
self.MODEL_PROVIDER_NAME,
self.service_url() if hasattr(self, "service_url") else None,
chat_options,
),
end_on_exit=True,
) as current_span:
_set_chat_response_input(self.MODEL_PROVIDER_NAME, messages)
try:
response = await completion_func(self, messages=messages, chat_options=chat_options, **kwargs)
_set_chat_response_output(current_span, response, self.MODEL_PROVIDER_NAME)
return response
except Exception as exception:
_set_chat_response_error(current_span, exception)
raise
# Mark the wrapper decorator as a chat completion decorator
wrap_inner_get_response.__model_diagnostics_chat_client__ = True # type: ignore
return wrap_inner_get_response
def _trace_chat_get_streaming_response(
completion_func: Callable[..., AsyncIterable["ChatResponseUpdate"]],
) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]:
"""Decorator to trace streaming chat completion activities.
Args:
completion_func: The function to trace.
"""
@functools.wraps(completion_func)
async def wrap_inner_get_streaming_response(
self: "ChatClientBase", *, messages: MutableSequence["ChatMessage"], chat_options: "ChatOptions", **kwargs: Any
) -> AsyncIterable["ChatResponseUpdate"]:
if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED:
# If model diagnostics are not enabled, just return the completion
async for streaming_chat_message_contents in completion_func(
self, messages=messages, chat_options=chat_options, **kwargs
):
yield streaming_chat_message_contents
return
from ._types import ChatResponse
all_updates: list["ChatResponseUpdate"] = []
with use_span(
_get_chat_response_span(
GenAIAttributes.CHAT_STREAMING_COMPLETION_OPERATION.value,
getattr(self, "ai_model_id", chat_options.ai_model_id or "unknown"),
self.MODEL_PROVIDER_NAME,
self.service_url() if hasattr(self, "service_url") else None,
chat_options,
),
end_on_exit=True,
) as current_span:
_set_chat_response_input(self.MODEL_PROVIDER_NAME, messages)
try:
async for response in completion_func(self, messages=messages, chat_options=chat_options, **kwargs):
all_updates.append(response)
yield response
all_messages_flattened = ChatResponse.from_chat_response_updates(all_updates)
_set_chat_response_output(current_span, all_messages_flattened, self.MODEL_PROVIDER_NAME)
except Exception as exception:
_set_chat_response_error(current_span, exception)
raise
# Mark the wrapper decorator as a streaming chat completion decorator
wrap_inner_get_streaming_response.__model_diagnostics_streaming_chat_completion__ = True # type: ignore
return wrap_inner_get_streaming_response
def use_telemetry(cls: type[TChatClientBase]) -> type[TChatClientBase]:
"""Class decorator that enables telemetry for a chat client.
Remarks:
This only works on classes that derive from ChatClientBase
and the _inner_get_response
and _inner_get_streaming_response methods.
It also relies on the presence of the MODEL_PROVIDER_NAME class variable.
```
"""
if inner_response := getattr(cls, "_inner_get_response", None):
cls._inner_get_response = _trace_chat_get_response(inner_response) # type: ignore
if inner_streaming_response := getattr(cls, "_inner_get_streaming_response", None):
cls._inner_get_streaming_response = _trace_chat_get_streaming_response(inner_streaming_response) # type: ignore
return cls
def _get_chat_response_span(
operation_name: str,
model_name: str,
model_provider: str,
service_url: str | None,
chat_options: "ChatOptions",
) -> Span:
"""Start a text or chat completion span for a given model.
Note that `start_span` doesn't make the span the current span.
Use `use_span` to make it the current span as a context manager.
"""
span = tracer.start_span(f"{operation_name} {model_name}")
# Set attributes on the span
span.set_attributes({
GenAIAttributes.OPERATION.value: operation_name,
GenAIAttributes.SYSTEM.value: model_provider,
GenAIAttributes.MODEL.value: model_name,
})
if service_url:
span.set_attribute(GenAIAttributes.ADDRESS.value, service_url)
if chat_options.seed is not None:
span.set_attribute(GenAIAttributes.SEED.value, chat_options.seed)
if chat_options.frequency_penalty is not None:
span.set_attribute(GenAIAttributes.FREQUENCY_PENALTY.value, chat_options.frequency_penalty)
if chat_options.max_tokens is not None:
span.set_attribute(GenAIAttributes.MAX_TOKENS.value, chat_options.max_tokens)
if chat_options.stop is not None:
span.set_attribute(GenAIAttributes.STOP_SEQUENCES.value, chat_options.stop)
if chat_options.temperature is not None:
span.set_attribute(GenAIAttributes.TEMPERATURE.value, chat_options.temperature)
if chat_options.top_p is not None:
span.set_attribute(GenAIAttributes.TOP_P.value, chat_options.top_p)
if "top_k" in chat_options.additional_properties:
span.set_attribute(GenAIAttributes.TOP_K.value, chat_options.additional_properties["top_k"])
if "encoding_formats" in chat_options.additional_properties:
span.set_attribute(
GenAIAttributes.ENCODING_FORMATS.value, chat_options.additional_properties["encoding_formats"]
)
return span
def _set_chat_response_input(
model_provider: str,
messages: MutableSequence["ChatMessage"],
) -> None:
"""Set the input for a chat response.
The logs will be associated to the current span.
"""
if MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED:
for idx, message in enumerate(messages):
event_name = ROLE_EVENT_MAP.get(message.role.value)
if event_name:
logger.info(
message.model_dump_json(exclude_none=True),
extra={
GenAIAttributes.EVENT_NAME.value: event_name,
GenAIAttributes.SYSTEM.value: model_provider,
ChatMessageListTimestampFilter.INDEX_KEY: idx,
},
)
def _set_chat_response_output(
current_span: Span,
response: "ChatResponse",
model_provider: str,
) -> None:
"""Set the response for a given span."""
first_completion = response.messages[0]
# Set the response ID
response_id = (
first_completion.additional_properties.get("id") if first_completion.additional_properties is not None else None
)
if response_id:
current_span.set_attribute(GenAIAttributes.RESPONSE_ID.value, response_id)
# Set the finish reason
finish_reason = response.finish_reason
if finish_reason:
current_span.set_attribute(GenAIAttributes.FINISH_REASON.value, finish_reason.value)
# Set usage attributes
usage = response.usage_details
if usage:
if usage.input_token_count:
current_span.set_attribute(GenAIAttributes.INPUT_TOKENS.value, usage.input_token_count)
if usage.output_token_count:
current_span.set_attribute(GenAIAttributes.OUTPUT_TOKENS.value, usage.output_token_count)
# Set the completion event
if MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED:
for completion in response.messages:
full_response: dict[str, Any] = {
"message": completion.model_dump(exclude_none=True),
}
full_response["index"] = response.response_id
logger.info(
json.dumps(full_response),
extra={
GenAIAttributes.EVENT_NAME.value: GenAIAttributes.CHOICE.value,
GenAIAttributes.SYSTEM.value: model_provider,
},
)
def _set_chat_response_error(span: Span, error: Exception) -> None:
"""Set an error for chat client responses."""
span.set_attribute(GenAIAttributes.ERROR_TYPE.value, str(type(error)))
span.set_status(StatusCode.ERROR, repr(error))