chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import functools
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from functools import reduce
|
||||
from typing import ParamSpec, cast
|
||||
|
||||
from opentelemetry.trace import Span, StatusCode, get_tracer
|
||||
|
||||
from semantic_kernel.agents.agent import Agent, AgentResponseItem
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
from semantic_kernel.utils.telemetry.agent_diagnostics import gen_ai_attributes
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
|
||||
|
||||
# Module to instrument GenAI agents using OpenTelemetry and OpenTelemetry Semantic Conventions.
|
||||
# These are experimental features and may change in the future.
|
||||
|
||||
# To enable these features, set one of the following environment variables to true:
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
|
||||
# We are re-using the model diagnostic settings to control the instrumentation of agents
|
||||
# because it makes sense to have a system wide setting for diagnostics. The name "model"
|
||||
# is a legacy name because the concept of agent was not yet introduced when the settings were created.
|
||||
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
# Creates a tracer from the global tracer provider
|
||||
tracer = get_tracer(__name__)
|
||||
|
||||
OPERATION_NAME = "invoke_agent"
|
||||
|
||||
|
||||
@experimental
|
||||
def are_model_diagnostics_enabled() -> bool:
|
||||
"""Check if model diagnostics are enabled.
|
||||
|
||||
Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled.
|
||||
"""
|
||||
return (
|
||||
MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics
|
||||
or MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def are_sensitive_events_enabled() -> bool:
|
||||
"""Check if sensitive events are enabled.
|
||||
|
||||
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
|
||||
"""
|
||||
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_agent_get_response(
|
||||
get_response_func: Callable[P, Awaitable[AgentResponseItem[ChatMessageContent]]],
|
||||
) -> Callable[P, Awaitable[AgentResponseItem[ChatMessageContent]]]:
|
||||
"""Decorator to trace agent invocation."""
|
||||
|
||||
@functools.wraps(get_response_func)
|
||||
async def wrapper_decorator(
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the responses
|
||||
return await get_response_func(*args, **kwargs)
|
||||
|
||||
agent = cast(Agent, args[0])
|
||||
messages = args[1] if len(args) > 1 else None
|
||||
|
||||
with _start_as_current_span(agent) as span:
|
||||
try:
|
||||
_set_agent_invocation_input(span, messages) # type: ignore
|
||||
response = await get_response_func(*args, **kwargs)
|
||||
_set_agent_invocation_output(span, [response.message])
|
||||
return response
|
||||
except Exception as e:
|
||||
_set_agent_invocation_error(span, e)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as an agent diagnostics decorator
|
||||
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
|
||||
|
||||
return wrapper_decorator
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_agent_invocation(
|
||||
invoke_func: Callable[P, AsyncIterable[AgentResponseItem[ChatMessageContent]]],
|
||||
) -> Callable[P, AsyncIterable[AgentResponseItem[ChatMessageContent]]]:
|
||||
"""Decorator to trace agent invocation."""
|
||||
|
||||
@functools.wraps(invoke_func)
|
||||
async def wrapper_decorator(
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the responses
|
||||
async for response in invoke_func(*args, **kwargs):
|
||||
yield response
|
||||
return
|
||||
|
||||
agent = cast(Agent, args[0])
|
||||
messages = args[1] if len(args) > 1 else None
|
||||
|
||||
with _start_as_current_span(agent) as current_span:
|
||||
_set_agent_invocation_input(current_span, messages) # type: ignore
|
||||
try:
|
||||
responses: list[ChatMessageContent] = []
|
||||
async for response in invoke_func(*args, **kwargs):
|
||||
responses.append(response.message)
|
||||
yield response
|
||||
_set_agent_invocation_output(current_span, responses)
|
||||
except Exception as e:
|
||||
_set_agent_invocation_error(current_span, e)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as an agent diagnostics decorator
|
||||
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
|
||||
|
||||
return wrapper_decorator
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_agent_streaming_invocation(
|
||||
invoke_func: Callable[P, AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]],
|
||||
) -> Callable[P, AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]]:
|
||||
"""Decorator to trace agent streaming invocation."""
|
||||
|
||||
@functools.wraps(invoke_func)
|
||||
async def wrapper_decorator(
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the responses
|
||||
async for chunk in invoke_func(*args, **kwargs):
|
||||
yield chunk
|
||||
return
|
||||
|
||||
agent = cast(Agent, args[0])
|
||||
messages = args[1] if len(args) > 1 else None
|
||||
|
||||
with _start_as_current_span(agent) as current_span:
|
||||
_set_agent_invocation_input(current_span, messages) # type: ignore
|
||||
try:
|
||||
chunks: list[StreamingChatMessageContent] = []
|
||||
async for chunk in invoke_func(*args, **kwargs):
|
||||
chunks.append(chunk.message)
|
||||
yield chunk
|
||||
# Concatenate the streaming chunks
|
||||
if chunks:
|
||||
response = reduce(lambda x, y: x + y, chunks)
|
||||
_set_agent_invocation_output(current_span, [response])
|
||||
else:
|
||||
_set_agent_invocation_output(current_span, [])
|
||||
except Exception as e:
|
||||
_set_agent_invocation_error(current_span, e)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as an agent diagnostics decorator
|
||||
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
|
||||
|
||||
return wrapper_decorator
|
||||
|
||||
|
||||
def _start_as_current_span(agent: Agent):
|
||||
"""Starts a span for the given agent.
|
||||
|
||||
Args:
|
||||
agent (Agent): The agent for which to start the span.
|
||||
|
||||
Returns:
|
||||
Span: The started span as a context manager.
|
||||
"""
|
||||
attributes = {
|
||||
gen_ai_attributes.OPERATION: OPERATION_NAME,
|
||||
gen_ai_attributes.AGENT_ID: agent.id,
|
||||
gen_ai_attributes.AGENT_NAME: agent.name,
|
||||
}
|
||||
|
||||
if agent.description:
|
||||
attributes[gen_ai_attributes.AGENT_DESCRIPTION] = agent.description
|
||||
|
||||
if agent.kernel.plugins:
|
||||
# This will only capture the tools that are available in the kernel at the time of agent creation.
|
||||
# If the agent is invoked with another kernel instance, the tools in that kernel will not be captured.
|
||||
from semantic_kernel.connectors.ai.function_calling_utils import (
|
||||
kernel_function_metadata_to_function_call_format,
|
||||
)
|
||||
|
||||
tool_definitions = [
|
||||
kernel_function_metadata_to_function_call_format(metadata)
|
||||
for metadata in agent.kernel.get_full_list_of_function_metadata()
|
||||
]
|
||||
attributes[gen_ai_attributes.AGENT_TOOL_DEFINITIONS] = json.dumps(tool_definitions)
|
||||
|
||||
return tracer.start_as_current_span(f"{OPERATION_NAME} {agent.name}", attributes=attributes)
|
||||
|
||||
|
||||
def _set_agent_invocation_input(
|
||||
current_span: Span,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None,
|
||||
) -> None:
|
||||
"""Set the agent input attributes in the span."""
|
||||
if are_sensitive_events_enabled():
|
||||
parsed_messages = _parse_agent_invocation_messages(messages)
|
||||
current_span.set_attribute(
|
||||
gen_ai_attributes.AGENT_INVOCATION_INPUT,
|
||||
json.dumps([message.to_dict() for message in parsed_messages]),
|
||||
)
|
||||
|
||||
|
||||
def _set_agent_invocation_output(current_span: Span, response: list[ChatMessageContent]) -> None:
|
||||
"""Set the agent output attributes in the span."""
|
||||
if are_sensitive_events_enabled():
|
||||
current_span.set_attribute(
|
||||
gen_ai_attributes.AGENT_INVOCATION_OUTPUT,
|
||||
json.dumps([message.to_dict() for message in response]),
|
||||
)
|
||||
|
||||
|
||||
def _set_agent_invocation_error(current_span: Span, error: Exception) -> None:
|
||||
"""Set the agent error attributes in the span."""
|
||||
current_span.set_attribute(gen_ai_attributes.ERROR_TYPE, type(error).__name__)
|
||||
current_span.set_status(StatusCode.ERROR, repr(error))
|
||||
|
||||
|
||||
def _parse_agent_invocation_messages(
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None,
|
||||
) -> list[ChatMessageContent]:
|
||||
"""Parse the agent invocation messages into a list of ChatMessageContent."""
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
if isinstance(messages, str):
|
||||
return [ChatMessageContent(role=AuthorRole.USER, content=messages)]
|
||||
if isinstance(messages, ChatMessageContent):
|
||||
return [messages]
|
||||
if isinstance(messages, list):
|
||||
return [
|
||||
msg if isinstance(msg, ChatMessageContent) else ChatMessageContent(role=AuthorRole.USER, content=msg)
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Constants for tracing agent activities with semantic conventions.
|
||||
# Ideally, we should use the attributes from the semcov package.
|
||||
# However, many of the attributes are not yet available in the package,
|
||||
# so we define them here for now.
|
||||
|
||||
# Activity tags
|
||||
OPERATION = "gen_ai.operation.name"
|
||||
AGENT_ID = "gen_ai.agent.id"
|
||||
AGENT_NAME = "gen_ai.agent.name"
|
||||
AGENT_DESCRIPTION = "gen_ai.agent.description"
|
||||
AGENT_INVOCATION_INPUT = "gen_ai.input.messages"
|
||||
AGENT_INVOCATION_OUTPUT = "gen_ai.output.messages"
|
||||
AGENT_TOOL_DEFINITIONS = "gen_ai.tool.definitions"
|
||||
ERROR_TYPE = "error.type"
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics.decorators import (
|
||||
trace_chat_completion,
|
||||
trace_streaming_chat_completion,
|
||||
trace_streaming_text_completion,
|
||||
trace_text_completion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"trace_chat_completion",
|
||||
"trace_streaming_chat_completion",
|
||||
"trace_streaming_text_completion",
|
||||
"trace_text_completion",
|
||||
]
|
||||
@@ -0,0 +1,453 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from functools import reduce
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from opentelemetry.trace import Span, StatusCode, get_tracer, use_span
|
||||
|
||||
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics import gen_ai_attributes
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
|
||||
|
||||
# Module to instrument GenAI models using OpenTelemetry and OpenTelemetry Semantic Conventions.
|
||||
# These are experimental features and may change in the future.
|
||||
|
||||
# To enable these features, set one of the following environment variables to true:
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
|
||||
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
|
||||
|
||||
# Operation names
|
||||
CHAT_COMPLETION_OPERATION = "chat"
|
||||
TEXT_COMPLETION_OPERATION = "text_completions"
|
||||
|
||||
|
||||
# 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 ChatHistoryMessageTimestampFilter(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
|
||||
tracer = get_tracer(__name__)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addFilter(ChatHistoryMessageTimestampFilter())
|
||||
|
||||
|
||||
@experimental
|
||||
def are_model_diagnostics_enabled() -> bool:
|
||||
"""Check if model diagnostics are enabled.
|
||||
|
||||
Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled.
|
||||
"""
|
||||
return (
|
||||
MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics
|
||||
or MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def are_sensitive_events_enabled() -> bool:
|
||||
"""Check if sensitive events are enabled.
|
||||
|
||||
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
|
||||
"""
|
||||
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_chat_completion(model_provider: str) -> Callable:
|
||||
"""Decorator to trace chat completion activities.
|
||||
|
||||
Args:
|
||||
model_provider (str): The model provider should describe a family of
|
||||
GenAI models with specific model identified by ai_model_id. For example,
|
||||
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
|
||||
Sometimes the model provider is unknown at runtime, in which case it can be
|
||||
set to the most specific known provider. For example, while using local models
|
||||
hosted by Ollama, the model provider could be set to "ollama".
|
||||
"""
|
||||
|
||||
def inner_trace_chat_completion(completion_func: Callable) -> Callable:
|
||||
@functools.wraps(completion_func)
|
||||
async def wrapper_decorator(*args: Any, **kwargs: Any) -> list[ChatMessageContent]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the completion
|
||||
return await completion_func(*args, **kwargs)
|
||||
|
||||
completion_service: "ChatCompletionClientBase" = args[0]
|
||||
chat_history: ChatHistory = kwargs.get("chat_history") or args[1] # type: ignore
|
||||
settings: "PromptExecutionSettings" = kwargs.get("settings") or args[2] # type: ignore
|
||||
|
||||
with use_span(
|
||||
_get_completion_span(
|
||||
CHAT_COMPLETION_OPERATION,
|
||||
completion_service.ai_model_id,
|
||||
model_provider,
|
||||
completion_service.service_url(),
|
||||
settings,
|
||||
),
|
||||
end_on_exit=True,
|
||||
) as current_span:
|
||||
_set_completion_input(model_provider, chat_history)
|
||||
try:
|
||||
completions: list[ChatMessageContent] = await completion_func(*args, **kwargs)
|
||||
_set_completion_response(current_span, completions, model_provider)
|
||||
return completions
|
||||
except Exception as exception:
|
||||
_set_completion_error(current_span, exception)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as a chat completion decorator
|
||||
wrapper_decorator.__model_diagnostics_chat_completion__ = True # type: ignore
|
||||
|
||||
return wrapper_decorator
|
||||
|
||||
return inner_trace_chat_completion
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_streaming_chat_completion(model_provider: str) -> Callable:
|
||||
"""Decorator to trace streaming chat completion activities.
|
||||
|
||||
Args:
|
||||
model_provider (str): The model provider should describe a family of
|
||||
GenAI models with specific model identified by ai_model_id. For example,
|
||||
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
|
||||
Sometimes the model provider is unknown at runtime, in which case it can be
|
||||
set to the most specific known provider. For example, while using local models
|
||||
hosted by Ollama, the model provider could be set to "ollama".
|
||||
"""
|
||||
|
||||
def inner_trace_streaming_chat_completion(completion_func: Callable) -> Callable:
|
||||
@functools.wraps(completion_func)
|
||||
async def wrapper_decorator(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> AsyncGenerator[list["StreamingChatMessageContent"], Any]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the completion
|
||||
async for streaming_chat_message_contents in completion_func(*args, **kwargs):
|
||||
yield streaming_chat_message_contents
|
||||
return
|
||||
|
||||
completion_service: "ChatCompletionClientBase" = args[0]
|
||||
chat_history: ChatHistory = kwargs.get("chat_history") or args[1] # type: ignore
|
||||
settings: "PromptExecutionSettings" = kwargs.get("settings") or args[2] # type: ignore
|
||||
|
||||
all_messages: dict[int, list[StreamingChatMessageContent]] = {}
|
||||
|
||||
with use_span(
|
||||
_get_completion_span(
|
||||
CHAT_COMPLETION_OPERATION,
|
||||
completion_service.ai_model_id,
|
||||
model_provider,
|
||||
completion_service.service_url(),
|
||||
settings,
|
||||
),
|
||||
end_on_exit=True,
|
||||
) as current_span:
|
||||
_set_completion_input(model_provider, chat_history)
|
||||
try:
|
||||
async for streaming_chat_message_contents in completion_func(*args, **kwargs):
|
||||
for streaming_chat_message_content in streaming_chat_message_contents:
|
||||
choice_index = streaming_chat_message_content.choice_index
|
||||
if choice_index not in all_messages:
|
||||
all_messages[choice_index] = []
|
||||
all_messages[choice_index].append(streaming_chat_message_content)
|
||||
yield streaming_chat_message_contents
|
||||
|
||||
all_messages_flattened = [
|
||||
reduce(lambda x, y: x + y, messages) for messages in all_messages.values()
|
||||
]
|
||||
_set_completion_response(current_span, all_messages_flattened, model_provider)
|
||||
except Exception as exception:
|
||||
_set_completion_error(current_span, exception)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as a streaming chat completion decorator
|
||||
wrapper_decorator.__model_diagnostics_streaming_chat_completion__ = True # type: ignore
|
||||
return wrapper_decorator
|
||||
|
||||
return inner_trace_streaming_chat_completion
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_text_completion(model_provider: str) -> Callable:
|
||||
"""Decorator to trace text completion activities.
|
||||
|
||||
Args:
|
||||
model_provider (str): The model provider should describe a family of
|
||||
GenAI models with specific model identified by ai_model_id. For example,
|
||||
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
|
||||
Sometimes the model provider is unknown at runtime, in which case it can be
|
||||
set to the most specific known provider. For example, while using local models
|
||||
hosted by Ollama, the model provider could be set to "ollama".
|
||||
"""
|
||||
|
||||
def inner_trace_text_completion(completion_func: Callable) -> Callable:
|
||||
@functools.wraps(completion_func)
|
||||
async def wrapper_decorator(*args: Any, **kwargs: Any) -> list[TextContent]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the completion
|
||||
return await completion_func(*args, **kwargs)
|
||||
|
||||
completion_service: "TextCompletionClientBase" = args[0]
|
||||
prompt: str = kwargs.get("prompt") if kwargs.get("prompt") is not None else args[1] # type: ignore
|
||||
settings: "PromptExecutionSettings" = kwargs["settings"] if kwargs.get("settings") is not None else args[2]
|
||||
|
||||
with use_span(
|
||||
_get_completion_span(
|
||||
TEXT_COMPLETION_OPERATION,
|
||||
completion_service.ai_model_id,
|
||||
model_provider,
|
||||
completion_service.service_url(),
|
||||
settings,
|
||||
),
|
||||
end_on_exit=True,
|
||||
) as current_span:
|
||||
_set_completion_input(model_provider, prompt)
|
||||
try:
|
||||
completions: list[TextContent] = await completion_func(*args, **kwargs)
|
||||
_set_completion_response(current_span, completions, model_provider)
|
||||
return completions
|
||||
except Exception as exception:
|
||||
_set_completion_error(current_span, exception)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as a text completion decorator
|
||||
wrapper_decorator.__model_diagnostics_text_completion__ = True # type: ignore
|
||||
|
||||
return wrapper_decorator
|
||||
|
||||
return inner_trace_text_completion
|
||||
|
||||
|
||||
@experimental
|
||||
def trace_streaming_text_completion(model_provider: str) -> Callable:
|
||||
"""Decorator to trace streaming text completion activities.
|
||||
|
||||
Args:
|
||||
model_provider (str): The model provider should describe a family of
|
||||
GenAI models with specific model identified by ai_model_id. For example,
|
||||
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
|
||||
Sometimes the model provider is unknown at runtime, in which case it can be
|
||||
set to the most specific known provider. For example, while using local models
|
||||
hosted by Ollama, the model provider could be set to "ollama".
|
||||
"""
|
||||
|
||||
def inner_trace_streaming_text_completion(completion_func: Callable) -> Callable:
|
||||
@functools.wraps(completion_func)
|
||||
async def wrapper_decorator(*args: Any, **kwargs: Any) -> AsyncGenerator[list["StreamingTextContent"], Any]:
|
||||
if not are_model_diagnostics_enabled():
|
||||
# If model diagnostics are not enabled, just return the completion
|
||||
async for streaming_text_contents in completion_func(*args, **kwargs):
|
||||
yield streaming_text_contents
|
||||
return
|
||||
|
||||
completion_service: "TextCompletionClientBase" = args[0]
|
||||
prompt: str = kwargs.get("prompt") if kwargs.get("prompt") is not None else args[1] # type: ignore
|
||||
settings: "PromptExecutionSettings" = kwargs["settings"] if kwargs.get("settings") is not None else args[2]
|
||||
|
||||
all_text_contents: dict[int, list["StreamingTextContent"]] = {}
|
||||
|
||||
with use_span(
|
||||
_get_completion_span(
|
||||
TEXT_COMPLETION_OPERATION,
|
||||
completion_service.ai_model_id,
|
||||
model_provider,
|
||||
completion_service.service_url(),
|
||||
settings,
|
||||
),
|
||||
end_on_exit=True,
|
||||
) as current_span:
|
||||
_set_completion_input(model_provider, prompt)
|
||||
try:
|
||||
async for streaming_text_contents in completion_func(*args, **kwargs):
|
||||
for streaming_text_content in streaming_text_contents:
|
||||
choice_index = streaming_text_content.choice_index
|
||||
if choice_index not in all_text_contents:
|
||||
all_text_contents[choice_index] = []
|
||||
all_text_contents[choice_index].append(streaming_text_content)
|
||||
yield streaming_text_contents
|
||||
|
||||
all_text_contents_flattened = [
|
||||
reduce(lambda x, y: x + y, messages) for messages in all_text_contents.values()
|
||||
]
|
||||
_set_completion_response(current_span, all_text_contents_flattened, model_provider)
|
||||
except Exception as exception:
|
||||
_set_completion_error(current_span, exception)
|
||||
raise
|
||||
|
||||
# Mark the wrapper decorator as a streaming text completion decorator
|
||||
wrapper_decorator.__model_diagnostics_streaming_text_completion__ = True # type: ignore
|
||||
return wrapper_decorator
|
||||
|
||||
return inner_trace_streaming_text_completion
|
||||
|
||||
|
||||
def _get_completion_span(
|
||||
operation_name: str,
|
||||
model_name: str,
|
||||
model_provider: str,
|
||||
service_url: str | None,
|
||||
execution_settings: "PromptExecutionSettings | None",
|
||||
) -> 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({
|
||||
gen_ai_attributes.OPERATION: operation_name,
|
||||
gen_ai_attributes.SYSTEM: model_provider,
|
||||
gen_ai_attributes.MODEL: model_name,
|
||||
})
|
||||
|
||||
if service_url:
|
||||
span.set_attribute(gen_ai_attributes.ADDRESS, service_url)
|
||||
|
||||
# TODO(@glahaye): we'll need to have a way to get these attributes from model
|
||||
# providers other than OpenAI (for example if the attributes are named differently)
|
||||
if execution_settings:
|
||||
attribute_name_map = {
|
||||
"seed": gen_ai_attributes.SEED,
|
||||
"encoding_formats": gen_ai_attributes.ENCODING_FORMATS,
|
||||
"frequency_penalty": gen_ai_attributes.FREQUENCY_PENALTY,
|
||||
"max_tokens": gen_ai_attributes.MAX_TOKENS,
|
||||
"stop_sequences": gen_ai_attributes.STOP_SEQUENCES,
|
||||
"temperature": gen_ai_attributes.TEMPERATURE,
|
||||
"top_k": gen_ai_attributes.TOP_K,
|
||||
"top_p": gen_ai_attributes.TOP_P,
|
||||
}
|
||||
for attribute_name, attribute_key in attribute_name_map.items():
|
||||
attribute = execution_settings.extension_data.get(attribute_name)
|
||||
if attribute:
|
||||
span.set_attribute(attribute_key, attribute)
|
||||
|
||||
return span
|
||||
|
||||
|
||||
def _set_completion_input(
|
||||
model_provider: str,
|
||||
prompt: str | ChatHistory,
|
||||
) -> None:
|
||||
"""Set the input for a text or chat completion.
|
||||
|
||||
The logs will be associated to the current span.
|
||||
"""
|
||||
if are_sensitive_events_enabled():
|
||||
if isinstance(prompt, ChatHistory):
|
||||
for idx, message in enumerate(prompt.messages):
|
||||
event_name = gen_ai_attributes.ROLE_EVENT_MAP.get(message.role)
|
||||
if event_name:
|
||||
logger.info(
|
||||
json.dumps(message.to_dict()),
|
||||
extra={
|
||||
gen_ai_attributes.EVENT_NAME: event_name,
|
||||
gen_ai_attributes.SYSTEM: model_provider,
|
||||
ChatHistoryMessageTimestampFilter.INDEX_KEY: idx,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
prompt,
|
||||
extra={
|
||||
gen_ai_attributes.EVENT_NAME: gen_ai_attributes.PROMPT,
|
||||
gen_ai_attributes.SYSTEM: model_provider,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _set_completion_response(
|
||||
current_span: Span,
|
||||
completions: list[ChatMessageContent]
|
||||
| list[TextContent]
|
||||
| list[StreamingChatMessageContent]
|
||||
| list[StreamingTextContent],
|
||||
model_provider: str,
|
||||
) -> None:
|
||||
"""Set the a text or chat completion response for a given span."""
|
||||
first_completion = completions[0]
|
||||
|
||||
# Set the response ID
|
||||
response_id = first_completion.metadata.get("id")
|
||||
if response_id:
|
||||
current_span.set_attribute(gen_ai_attributes.RESPONSE_ID, response_id)
|
||||
|
||||
# Set the finish reason
|
||||
finish_reasons = [
|
||||
str(completion.finish_reason) for completion in completions if isinstance(completion, ChatMessageContent)
|
||||
]
|
||||
if finish_reasons:
|
||||
current_span.set_attribute(gen_ai_attributes.FINISH_REASON, ",".join(finish_reasons))
|
||||
|
||||
# Set usage attributes
|
||||
usage = first_completion.metadata.get("usage", None)
|
||||
if isinstance(usage, CompletionUsage):
|
||||
if usage.prompt_tokens:
|
||||
current_span.set_attribute(gen_ai_attributes.INPUT_TOKENS, usage.prompt_tokens)
|
||||
if usage.completion_tokens:
|
||||
current_span.set_attribute(gen_ai_attributes.OUTPUT_TOKENS, usage.completion_tokens)
|
||||
|
||||
# Set the completion event
|
||||
if are_sensitive_events_enabled():
|
||||
for completion in completions:
|
||||
full_response: dict[str, Any] = {
|
||||
"message": completion.to_dict(),
|
||||
}
|
||||
|
||||
if isinstance(completion, ChatMessageContent):
|
||||
full_response["finish_reason"] = completion.finish_reason
|
||||
if isinstance(completion, StreamingContentMixin):
|
||||
full_response["index"] = completion.choice_index
|
||||
|
||||
logger.info(
|
||||
json.dumps(full_response),
|
||||
extra={
|
||||
gen_ai_attributes.EVENT_NAME: gen_ai_attributes.CHOICE,
|
||||
gen_ai_attributes.SYSTEM: model_provider,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _set_completion_error(span: Span, error: Exception) -> None:
|
||||
"""Set an error for a text or chat completion ."""
|
||||
span.set_attribute(gen_ai_attributes.ERROR_TYPE, str(type(error)))
|
||||
span.set_status(StatusCode.ERROR, repr(error))
|
||||
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics.gen_ai_attributes import (
|
||||
OPERATION,
|
||||
TOOL_CALL_ID,
|
||||
TOOL_DESCRIPTION,
|
||||
TOOL_NAME,
|
||||
)
|
||||
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.functions.kernel_function import KernelFunction
|
||||
|
||||
|
||||
# The operation name is defined by OTeL GenAI semantic conventions:
|
||||
# https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#execute-tool-span
|
||||
OPERATION_NAME = "execute_tool"
|
||||
|
||||
# To enable these features, set one of the following environment variables to true:
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
|
||||
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
|
||||
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
|
||||
|
||||
|
||||
@experimental
|
||||
def are_sensitive_events_enabled() -> bool:
|
||||
"""Check if sensitive events are enabled.
|
||||
|
||||
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
|
||||
"""
|
||||
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
|
||||
|
||||
|
||||
def start_as_current_span(
|
||||
tracer: trace.Tracer,
|
||||
function: "KernelFunction",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Starts a span for the given function using the provided tracer.
|
||||
|
||||
Args:
|
||||
tracer (trace.Tracer): The OpenTelemetry tracer to use.
|
||||
function (KernelFunction): The function for which to start the span.
|
||||
metadata (dict[str, Any] | None): Optional metadata to include in the span attributes.
|
||||
|
||||
Returns:
|
||||
trace.Span: The started span as a context manager.
|
||||
"""
|
||||
attributes = {
|
||||
OPERATION: OPERATION_NAME,
|
||||
TOOL_NAME: function.fully_qualified_name,
|
||||
}
|
||||
|
||||
tool_call_id = metadata.get("id", None) if metadata else None
|
||||
if tool_call_id:
|
||||
attributes[TOOL_CALL_ID] = tool_call_id
|
||||
if function.description:
|
||||
attributes[TOOL_DESCRIPTION] = function.description
|
||||
|
||||
return tracer.start_as_current_span(f"{OPERATION_NAME} {function.fully_qualified_name}", attributes=attributes)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
# Constants for tracing activities with semantic conventions.
|
||||
# Ideally, we should use the attributes from the semcov package.
|
||||
# However, many of the attributes are not yet available in the package,
|
||||
# so we define them here for now.
|
||||
|
||||
# Activity tags
|
||||
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_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
|
||||
TOOL_CALL_RESULT = "gen_ai.tool.call.result"
|
||||
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"
|
||||
|
||||
# Kernel specific attributes
|
||||
AVAILABLE_FUNCTIONS = "sk.available_functions"
|
||||
|
||||
|
||||
ROLE_EVENT_MAP = {
|
||||
AuthorRole.SYSTEM: SYSTEM_MESSAGE,
|
||||
AuthorRole.USER: USER_MESSAGE,
|
||||
AuthorRole.ASSISTANT: ASSISTANT_MESSAGE,
|
||||
AuthorRole.TOOL: TOOL_MESSAGE,
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class ModelDiagnosticSettings(KernelBaseSettings):
|
||||
"""Settings for model diagnostics.
|
||||
|
||||
The settings are first loaded from environment variables with
|
||||
the prefix 'SEMANTICKERNEL_EXPERIMENTAL_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.
|
||||
|
||||
Required settings for prefix 'SEMANTICKERNEL_EXPERIMENTAL_GENAI_' are:
|
||||
- enable_otel_diagnostics: bool - Enable OpenTelemetry diagnostics. Default is False.
|
||||
(Env var SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS)
|
||||
- enable_otel_diagnostics_sensitive: bool - Enable OpenTelemetry sensitive events. Default is False.
|
||||
(Env var SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE)
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "SEMANTICKERNEL_EXPERIMENTAL_GENAI_"
|
||||
|
||||
enable_otel_diagnostics: bool = False
|
||||
enable_otel_diagnostics_sensitive: bool = False
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.const import USER_AGENT
|
||||
|
||||
# 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"]
|
||||
|
||||
HTTP_USER_AGENT = "semantic-kernel-python"
|
||||
|
||||
try:
|
||||
version_info = version("semantic-kernel")
|
||||
except PackageNotFoundError:
|
||||
version_info = "dev"
|
||||
|
||||
APP_INFO = (
|
||||
{
|
||||
"semantic-kernel-version": f"python/{version_info}",
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
SEMANTIC_KERNEL_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
|
||||
|
||||
|
||||
def prepend_semantic_kernel_to_user_agent(headers: dict[str, Any]):
|
||||
"""Prepend "semantic-kernel" to the User-Agent in the headers.
|
||||
|
||||
Args:
|
||||
headers: The existing headers dictionary.
|
||||
|
||||
Returns:
|
||||
The modified headers dictionary with "semantic-kernel-python/{version}" prepended to the User-Agent.
|
||||
"""
|
||||
headers[USER_AGENT] = (
|
||||
f"{SEMANTIC_KERNEL_USER_AGENT} {headers[USER_AGENT]}" if USER_AGENT in headers else SEMANTIC_KERNEL_USER_AGENT
|
||||
)
|
||||
|
||||
return headers
|
||||
Reference in New Issue
Block a user