ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,87 @@
# coding: utf-8
# Copyright (c) 2025 inclusionAI.
from abc import ABC, abstractmethod
from typing import Any, Collection
from importlib_metadata import version, PackageNotFoundError
from aworld.logs.util import logger
from aworld.utils.import_package import import_package
import_package("packaging") # noqa
from packaging.requirements import Requirement, InvalidRequirement
class Instrumentor(ABC):
_instance = None
_has_instrumented = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = object.__new__(cls)
return cls._instance
def instrument(self, **kwargs: Any):
"""
Instrument the library.
"""
if self._has_instrumented:
logger.warning(
f"Instrumentor[{self.__class__.__name__}] has already instrumented, skip")
return
if not self._check_dependency_conflicts():
return
result = self._instrument(**kwargs)
self._has_instrumented = True
return result
def uninstrument(self, **kwargs: Any):
"""
Uninstrument the library.
"""
if not self._has_instrumented:
logger.warning("Instrumentor has not instrumented, skip")
return
self._uninstrument(**kwargs)
self._has_instrumented = False
@abstractmethod
def _uninstrument(self, **kwargs: Any):
"""
Uninstrument the library.
"""
@abstractmethod
def _instrument(self, **kwargs: Any):
"""
Instrument the library.
"""
def _check_dependency_conflicts(self):
dependencies = self.instrumentation_dependencies()
for dependence in dependencies:
try:
requirement = Requirement(dependence)
except InvalidRequirement as exc:
logger.warning(
f'error parsing dependency, reporting as a conflict: "{dependence}" - {exc}')
return False
try:
dist_version = version(requirement.name)
except PackageNotFoundError as exc:
logger.warning(
f'dependency not found, reporting as a conflict: "{dependence}" - {exc}')
return False
if requirement.specifier and not requirement.specifier.contains(dist_version):
logger.warning(
f'dependency version conflict, reporting as a conflict: requested: "{self.required}" but found: "{self.found}"')
return False
return True
@abstractmethod
def instrumentation_dependencies(self) -> Collection[str]:
"""
Return a list of dependencies that the instrumentation requires.
"""
@@ -0,0 +1,238 @@
import wrapt
import time
import traceback
import aworld.trace.constants as trace_constants
from typing import Collection, Any
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.instrumentation import semconv
from aworld.trace.base import (
Tracer,
SpanType,
get_tracer_provider_silent
)
from aworld.logs.util import logger
from aworld.metrics.context_manager import MetricContext
from aworld.metrics.template import MetricTemplate
from aworld.metrics.metric import MetricType
agent_duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="agent_run_duration",
unit="s",
description="Agent run duration",
)
agent_run_counter = MetricTemplate(
type=MetricType.COUNTER,
name="agent_run_counter",
unit="time",
description="Number of agent run or async run",
)
agent_usage_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="agent_token_usage",
unit="token",
description="Agent token usage"
)
def get_agent_span_attributes(instance, message):
return {
semconv.AGENT_ID: instance.id(),
semconv.AGENT_NAME: instance.name(),
semconv.TASK_ID: message.context.task_id if (message.context and message.context.task_id) else "",
semconv.SESSION_ID: message.context.session_id if (message.context and message.context.session_id) else message.session_id,
semconv.USER_ID: message.context.user if (message.context and message.context.user) else "",
trace_constants.ATTRIBUTES_MESSAGE_RUN_TYPE_KEY: trace_constants.RunType.AGNET.value
}
def _end_span(span):
if span:
span.end()
def _record_metric(duration, attributes, exception=None):
if MetricContext.metric_initialized():
MetricContext.histogram_record(agent_duration_histogram, duration, labels=attributes)
if exception:
run_counter_attr = {
semconv.AGENT_RUN_SUCCESS: "0",
"error.type": exception.__class__.__name__,
**attributes
}
else:
run_counter_attr = {
semconv.AGENT_RUN_SUCCESS: "1",
**attributes
}
MetricContext.count(agent_run_counter, 1, labels=run_counter_attr)
def _record_exception(span, start_time, exception, attributes):
try:
duration = time.time() - start_time if "start_time" in locals() else 0
if span and span.is_recording:
span.record_exception(exception=exception)
_record_metric(duration, attributes, exception)
except Exception as e:
logger.warning(f"agent instrument record exception error.{e}")
def _record_response(instance,
start_time,
response,
attributes):
try:
duration = time.time() - start_time if "start_time" in locals() else 0
_record_metric(duration, attributes)
# if instance and instance.agent_context and instance.agent_context.llm_output and MetricContext.metric_initialized():
# usage = instance.agent_context.llm_output.usage
# for usage_type in ["completion_tokens", "prompt_tokens", "total_tokens"]:
# if usage and usage.get(usage_type):
# labels = {
# **attributes,
# semconv.AGENT_USAGE_TYPE: usage_type
# }
# MetricContext.histogram_record(
# agent_usage_histogram,
# usage.get(usage_type),
# labels=labels
# )
except Exception as e:
logger.warning(f"agent instrument record response error.{e}")
def _async_run_class_wrapper(tracer: Tracer):
async def _async_run_wrapper(wrapped, instance, args, kwargs):
span = None
message = args[0] or kwargs.get("message")
attributes = get_agent_span_attributes(instance, message)
if tracer:
span = tracer.start_span(
name=trace_constants.SPAN_NAME_PREFIX_AGENT + "async_run",
span_type=SpanType.SERVER,
attributes=attributes
)
start_time = time.time()
try:
response = await wrapped(*args, **kwargs)
_record_response(instance, start_time, response, attributes)
except Exception as e:
_record_exception(span=span,
start_time=start_time,
exception=e,
attributes=attributes
)
_end_span(span)
raise e
_end_span(span)
return response
return _async_run_wrapper
async def _async_run_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def _awrapper(wrapped, instance, args, kwargs):
wrapper_func = _async_run_class_wrapper(tracer=tracer)
return await wrapper_func(wrapped, instance, args, kwargs)
return _awrapper
def _call_llm_model_class_wrapper(tracer: Tracer):
async def _call_llm_model_wrapper(wrapped, instance, args, kwargs):
attributes = {
semconv.AGENT_ID: instance.id(),
semconv.AGENT_NAME: instance.name()
}
if hasattr(instance, "context") and instance.context:
attributes.update({
semconv.TASK_ID: instance.context.task_id if (instance.context and instance.context.task_id) else "",
semconv.SESSION_ID: instance.context.session_id if (instance.context and instance.context.session_id) else instance.session_id,
semconv.USER_ID: instance.context.user if (instance.context and instance.context.user) else ""
})
try:
response = await wrapped(*args, **kwargs)
try:
usage = response.usage if hasattr(response, "usage") else None
for usage_type in ["completion_tokens", "prompt_tokens", "total_tokens"]:
if usage and usage.get(usage_type):
labels = {
**attributes,
semconv.AGENT_USAGE_TYPE: usage_type
}
MetricContext.histogram_record(
agent_usage_histogram,
usage.get(usage_type),
labels=labels
)
except Exception as e:
logger.warning(f"agent instrument record response error.{e}")
except Exception as e:
raise e
return response
return _call_llm_model_wrapper
async def _call_llm_model_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def _awrapper(wrapped, instance, args, kwargs):
wrapper_func = _call_llm_model_class_wrapper(tracer=tracer)
return await wrapper_func(wrapped, instance, args, kwargs)
return _awrapper
class AgentInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ()
def _instrument(self, **kwargs):
agent_trace_enabled = kwargs.get("trace_enabled", False)
tracer_provider = get_tracer_provider_silent()
tracer = None
if tracer_provider and agent_trace_enabled:
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.agent")
try:
wrapt.wrap_function_wrapper(
"aworld.core.agent.base",
"BaseAgent.async_run",
_async_run_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.agents.llm_agent",
"Agent.invoke_model",
_call_llm_model_class_wrapper(tracer=tracer)
)
except Exception as err:
logger.warning(f"AgentInstrumentor#_instrument failed ,err is {err}")
def _uninstrument(self, **kwargs: Any):
pass
def wrap_agent(agent: 'aworld.core.agent.base.BaseAgent'):
try:
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return agent
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.agent")
async_run_wrapper = _async_run_instance_wrapper(tracer)
agent.async_run = async_run_wrapper(agent.async_run)
if hasattr(agent, "_call_llm_model"):
call_llm_model_wrapper = _call_llm_model_instance_wrapper(tracer)
agent._call_llm_model = call_llm_model_wrapper(agent._call_llm_model)
except Exception:
logger.warning(traceback.format_exc())
return agent
@@ -0,0 +1,203 @@
from timeit import default_timer
from typing import Any, Awaitable, Callable
from functools import wraps
from aworld.metrics.context_manager import MetricContext
from aworld.trace.instrumentation.http_util import (
collect_request_attributes_asgi,
url_disabled,
parser_host_port_url_from_asgi
)
from aworld.trace.base import Span, TraceProvider, TraceContext, Tracer, SpanType
from aworld.trace.propagator import get_global_trace_propagator
from aworld.trace.propagator.carrier import DictCarrier, ListTupleCarrier
from aworld.metrics.metric import MetricType
from aworld.metrics.template import MetricTemplate
from aworld.logs.util import logger
def _wrapped_receive(
server_span: Span,
server_span_name: str,
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
attributes: dict[str],
client_request_hook: Callable = None
):
@wraps(receive)
async def otel_receive():
message = await receive()
if client_request_hook and callable(client_request_hook):
client_request_hook(scope, message)
server_span.set_attribute("asgi.event.type", message.get("type", ""))
return message
return otel_receive
def _wrapped_send(
server_span: Span,
server_span_name: str,
scope: dict[str, Any],
send: Callable[[dict[str, Any]], Awaitable[None]],
attributes: dict[str],
client_response_hook: Callable = None
):
expecting_trailers = False
@wraps(send)
async def otel_send(message: dict[str, Any]):
nonlocal expecting_trailers
status_code = None
if message["type"] == "http.response.start":
status_code = message["status"]
elif message["type"] == "websocket.send":
status_code = 200
# raw_headers = message.get("headers")
# if raw_headers:
if status_code:
server_span.set_attribute(
"http.response.status_code", status_code)
if callable(client_response_hook):
client_response_hook(scope, message)
if message["type"] == "http.response.start":
expecting_trailers = message.get("trailers", False)
propagator = get_global_trace_propagator()
if propagator:
trace_context = TraceContext(
trace_id=server_span.get_trace_id(),
span_id=server_span.get_span_id()
)
propagator.inject(
trace_context, DictCarrier(message))
await send(message)
if (
not expecting_trailers
and message["type"] == "http.response.body"
and not message.get("more_body", False)
) or (
expecting_trailers
and message["type"] == "http.response.trailers"
and not message.get("more_trailers", False)
):
server_span.end()
return otel_send
class TraceMiddleware:
"""
A ASGI Middleware for tracing requests and responses.
"""
def __init__(
self,
app,
excluded_urls=None,
tracer_provider: TraceProvider = None,
tracer: Tracer = None,
server_request_hook: Callable = None,
client_request_hook: Callable = None,
client_response_hook: Callable = None,):
self.app = app
self.excluded_urls = excluded_urls
self.tracer_provider = tracer_provider
self.server_request_hook = server_request_hook
self.client_request_hook = client_request_hook
self.client_response_hook = client_response_hook
self.tracer: Tracer = (self.tracer_provider.get_tracer(
"aworld.trace.instrumentation.asgi"
) if tracer is None else tracer)
self.duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="asgi_request_duration_histogram",
description="Duration of flask HTTP server requests."
)
self.active_requests_counter = MetricTemplate(
type=MetricType.UPDOWNCOUNTER,
name="asgi_active_request_counter",
unit="1",
description="Number of active HTTP server requests.",
)
async def __call__(
self,
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
):
start = default_timer()
if scope["type"] not in ("http", "websocket"):
return await self.app(scope, receive, send)
_, _, url = parser_host_port_url_from_asgi(scope)
if self.excluded_urls and url_disabled(url, self.excluded_urls):
return await self.app(scope, receive, send)
span_name = scope.get("method", "HTTP").strip(
).upper() + "_" + scope.get("path", "").strip()
attributes = collect_request_attributes_asgi(scope)
if scope["type"] == "http" and MetricContext.metric_initialized():
MetricContext.inc(self.active_requests_counter, 1, attributes)
trace_context = None
propagator = get_global_trace_propagator()
if propagator:
trace_context = propagator.extract(
ListTupleCarrier(scope.get("headers", [])))
logger.info(
f"asgi extract trace_context: {trace_context}, scope: {scope}")
try:
with self.tracer.start_as_current_span(
span_name, span_type=SpanType.SERVER, trace_context=trace_context, attributes=attributes
) as span:
if callable(self.server_request_hook):
self.server_request_hook(scope)
wrappered_receive = _wrapped_receive(
span,
span_name,
scope,
receive,
attributes,
self.client_request_hook
)
wrappered_send = _wrapped_send(
span,
span_name,
scope,
send,
attributes,
self.client_response_hook
)
await self.app(scope, wrappered_receive, wrappered_send)
finally:
if scope["type"] == "http":
duration_s = default_timer() - start
if MetricContext.metric_initialized():
MetricContext.histogram_record(
self.duration_histogram,
duration_s,
attributes
)
MetricContext.inc(
self.active_requests_counter, -1, attributes)
if span.is_recording():
span.end()
@@ -0,0 +1,115 @@
import wrapt
from typing import Any, Collection
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.base import Tracer, get_tracer_provider_silent, TraceContext
from aworld.trace.propagator import get_global_trace_propagator, get_global_trace_context
from aworld.trace.propagator.carrier import DictCarrier
from aworld.logs.util import logger
def _emit_message_class_wrapper(tracer: Tracer):
async def awrapper(wrapped, instance, args, kwargs):
from aworld.core.event.base import Message
try:
event = args[0] if len(args) > 0 else kwargs.get("event")
propagator = get_global_trace_propagator()
trace_provider = get_tracer_provider_silent()
if trace_provider and propagator and event and isinstance(event, Message):
if not event.headers:
event.headers = {}
current_span = trace_provider.get_current_span()
if current_span:
trace_context = TraceContext(
trace_id=current_span.get_trace_id(), span_id=current_span.get_span_id())
propagator.inject(trace_context=trace_context,
carrier=DictCarrier(event.headers))
logger.info(
f"EventManager emit_message trace propagate, event.headers={event.headers}")
except Exception as e:
logger.error(
f"EventManager emit_message trace propagate exception: {e}")
return await wrapped(*args, **kwargs)
return awrapper
def _emit_message_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def awrapper(wrapped, instance, args, kwargs):
wrapper = _emit_message_class_wrapper(tracer)
return await wrapper(wrapped, instance, args, kwargs)
return awrapper
def _consume_class_wrapper(tracer: Tracer):
async def awrapper(wrapped, instance, args, kwargs):
from aworld.core.event.base import Message
event = await wrapped(*args, **kwargs)
try:
propagator = get_global_trace_propagator()
if propagator and event and isinstance(event, Message) and event.headers:
trace_context = propagator.extract(DictCarrier(event.headers))
# logger.info(
# f"extract trace_context from event: {trace_context}")
if trace_context:
get_global_trace_context().set(trace_context)
except Exception as e:
logger.error(
f"EventManager consume trace propagate exception: {e}")
return event
return awrapper
def _consume_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def awrapper(wrapped, instance, args, kwargs):
wrapper = _consume_class_wrapper(tracer)
return await wrapper(wrapped, instance, args, kwargs)
return awrapper
class EventBusInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ()
def _uninstrument(self, **kwargs: Any):
pass
def _instrument(self, **kwargs: Any):
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.eventbus")
wrapt.wrap_function_wrapper(
"aworld.events.manager",
"EventManager.emit_message",
_emit_message_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.events.manager",
"EventManager.consume",
_consume_class_wrapper(tracer=tracer)
)
def wrap_event_manager(manager: 'aworld.events.manager.EventManager'):
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return manager
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.eventbus")
emit_wrapper = _emit_message_instance_wrapper(tracer)
consume_wrapper = _consume_instance_wrapper(tracer)
manager.emit_message = emit_wrapper(manager.emit_message)
manager.consume = consume_wrapper(manager.consume)
return manager
@@ -0,0 +1,107 @@
from typing import Any, Callable
from .asgi import TraceMiddleware
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.base import TraceProvider, get_tracer_provider
from aworld.trace.instrumentation.http_util import (
get_excluded_urls,
parse_excluded_urls,
)
from aworld.utils.import_package import import_packages
from aworld.logs.util import logger
import_packages(['fastapi']) # noqa
import fastapi # noqa
class _InstrumentedFastAPI(fastapi.FastAPI):
"""Instrumented FastAPI class."""
_tracer_provider: TraceProvider = None
_excluded_urls: list[str] = None
_server_request_hook: Callable = None
_client_request_hook: Callable = None
_client_response_hook: Callable = None
_instrumented_fastapi_apps = set()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tracer = self._tracer_provider.get_tracer(
"aworld.trace.instrumentation.fastapi")
self.add_middleware(
TraceMiddleware,
tracer=tracer,
excluded_urls=self._excluded_urls,
server_request_hook=self._server_request_hook,
client_request_hook=self._client_request_hook,
client_response_hook=self._client_response_hook
)
self._is_instrumented_by_trace = True
self._instrumented_fastapi_apps.add(self)
def __del__(self):
if self in self._instrumented_fastapi_apps:
self._instrumented_fastapi_apps.remove(self)
class FastAPIInstrumentor(Instrumentor):
"""FastAPI Instrumentor."""
_original_fastapi = None
@staticmethod
def uninstrument_app(app: fastapi.FastAPI):
app.user_middleware = [
x
for x in app.user_middleware
if x.cls is not TraceMiddleware
]
app.middleware_stack = app.build_middleware_stack()
app._is_instrumented_by_trace = False
def instrumentation_dependencies(self) -> dict[str, Any]:
return {"fastapi": fastapi}
def _instrument(self, **kwargs):
self._original_fastapi = fastapi.FastAPI
_InstrumentedFastAPI._tracer_provider = kwargs.get("tracer_provider")
_InstrumentedFastAPI._server_request_hook = kwargs.get(
"server_request_hook"
)
_InstrumentedFastAPI._client_request_hook = kwargs.get(
"client_request_hook"
)
_InstrumentedFastAPI._client_response_hook = kwargs.get(
"client_response_hook"
)
excluded_urls = kwargs.get("excluded_urls")
_InstrumentedFastAPI._excluded_urls = (
get_excluded_urls("FASTAPI")
if excluded_urls is None
else parse_excluded_urls(excluded_urls)
)
fastapi.FastAPI = _InstrumentedFastAPI
def _uninstrument(self, **kwargs):
for app in _InstrumentedFastAPI._instrumented_fastapi_apps:
self.uninstrument_app(app)
_InstrumentedFastAPI._instrumented_fastapi_apps.clear()
fastapi.FastAPI = self._original_fastapi
def instrument_fastapi(excluded_urls: str = None,
server_request_hook: Callable = None,
client_request_hook: Callable = None,
client_response_hook: Callable = None,
tracer_provider: TraceProvider = None,
**kwargs: Any,
):
kwargs.update({
"excluded_urls": excluded_urls,
"server_request_hook": server_request_hook,
"client_request_hook": client_request_hook,
"client_response_hook": client_response_hook,
"tracer_provider": tracer_provider or get_tracer_provider(),
})
FastAPIInstrumentor().instrument(**kwargs)
logger.info("FastAPI instrumented.")
@@ -0,0 +1,279 @@
import flask
import weakref
from typing import Any, Callable, Collection
from time import time_ns
from timeit import default_timer
from importlib_metadata import version
from packaging import version as package_version
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.base import Span, TraceProvider, TraceContext, Tracer, SpanType, get_tracer_provider
from aworld.metrics.metric import MetricType
from aworld.metrics.template import MetricTemplate
from aworld.logs.util import logger
from aworld.trace.instrumentation.http_util import (
collect_request_attributes,
url_disabled,
get_excluded_urls,
parse_excluded_urls,
HTTP_ROUTE
)
from aworld.trace.propagator import get_global_trace_propagator
from aworld.metrics.context_manager import MetricContext
from aworld.trace.propagator.carrier import ListTupleCarrier, DictCarrier
_ENVIRON_STARTTIME_KEY = "aworld-flask.starttime_key"
_ENVIRON_SPAN_KEY = "aworld-flask.span_key"
_ENVIRON_REQCTX_REF_KEY = "aworld-flask.reqctx_ref_key"
flask_version = version("flask")
if package_version.parse(flask_version) >= package_version.parse("2.2.0"):
def _request_ctx_ref() -> weakref.ReferenceType:
return weakref.ref(flask.globals.request_ctx._get_current_object())
else:
def _request_ctx_ref() -> weakref.ReferenceType:
return weakref.ref(flask._request_ctx_stack.top)
def _rewrapped_app(
wsgi_app,
active_requests_counter,
duration_histogram,
response_hook=None,
excluded_urls=None,
):
def _wrapped_app(wrapped_app_environ, start_response):
# We want to measure the time for route matching, etc.
# In theory, we could start the span here and use
# update_name later but that API is "highly discouraged" so
# we better avoid it.
wrapped_app_environ[_ENVIRON_STARTTIME_KEY] = time_ns()
start = default_timer()
attributes = collect_request_attributes(wrapped_app_environ)
if MetricContext.metric_initialized():
MetricContext.inc(active_requests_counter, 1, attributes)
request_route = None
def _start_response(status, response_headers, *args, **kwargs):
if flask.request and (
excluded_urls is None
or not url_disabled(flask.request.url, excluded_urls)
):
nonlocal request_route
request_route = flask.request.url_rule
span: Span = flask.request.environ.get(_ENVIRON_SPAN_KEY)
propagator = get_global_trace_propagator()
if propagator and span:
trace_context = TraceContext(
trace_id=span.get_trace_id(),
span_id=span.get_span_id()
)
propagator.inject(
trace_context, ListTupleCarrier(response_headers))
if span and span.is_recording():
status_code_str, _ = status.split(" ", 1)
try:
status_code = int(status_code_str)
except ValueError:
status_code = -1
span.set_attribute(
"http.response.status_code", status_code)
span.set_attributes(attributes)
if response_hook is not None:
response_hook(span, status, response_headers)
return start_response(status, response_headers, *args, **kwargs)
result = wsgi_app(wrapped_app_environ, _start_response)
duration_s = default_timer() - start
if MetricContext.metric_initialized():
MetricContext.histogram_record(
duration_histogram,
duration_s,
attributes
)
MetricContext.dec(active_requests_counter, 1, attributes)
return result
return _wrapped_app
def _wrapped_before_request(
request_hook=None,
tracer: Tracer = None,
excluded_urls=None
):
def _before_request():
if excluded_urls and url_disabled(flask.request.url, excluded_urls):
return
flask_request_environ = flask.request.environ
logger.info(
f"_wrapped_before_request flask_request_environ={flask_request_environ}")
attributes = collect_request_attributes(flask_request_environ)
if flask.request.url_rule:
# For 404 that result from no route found, etc, we
# don't have a url_rule.
attributes[HTTP_ROUTE] = flask.request.url_rule.rule
span_name = f"HTTP {flask.request.url_rule.rule}"
else:
span_name = f"HTTP {flask.request.url}"
propagator = get_global_trace_propagator()
trace_context = None
if propagator:
trace_context = propagator.extract(
DictCarrier(flask_request_environ))
logger.info(f"_wrapped_before_request trace_context={trace_context}")
span = tracer.start_span(
span_name,
SpanType.SERVER,
attributes=attributes,
start_time=flask_request_environ.get(_ENVIRON_STARTTIME_KEY),
trace_context=trace_context
)
if request_hook:
request_hook(span, flask_request_environ)
flask_request_environ[_ENVIRON_SPAN_KEY] = span
flask_request_environ[_ENVIRON_REQCTX_REF_KEY] = _request_ctx_ref()
return _before_request
def _wrapped_teardown_request(
excluded_urls=None,
):
def _teardown_request(exc):
if excluded_urls and url_disabled(flask.request.url, excluded_urls):
return
span: Span = flask.request.environ.get(_ENVIRON_SPAN_KEY)
original_reqctx_ref = flask.request.environ.get(
_ENVIRON_REQCTX_REF_KEY
)
current_reqctx_ref = _request_ctx_ref()
if not span or original_reqctx_ref != current_reqctx_ref:
return
if exc is None:
span.end()
else:
span.record_exception(exc)
span.end()
return _teardown_request
class _InstrumentedFlask(flask.Flask):
_excluded_urls = None
_tracer_provider: TraceProvider = None
_request_hook = None
_response_hook = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tracer = self._tracer_provider.get_tracer(
"aworld.trace.instrumentation.flask")
duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="flask_request_duration_histogram",
description="Duration of flask HTTP server requests."
)
active_requests_counter = MetricTemplate(
type=MetricType.UPDOWNCOUNTER,
name="flask_active_request_counter",
unit="1",
description="Number of active HTTP server requests.",
)
self.wsgi_app = _rewrapped_app(
self.wsgi_app,
active_requests_counter,
duration_histogram,
_InstrumentedFlask._response_hook,
excluded_urls=_InstrumentedFlask._excluded_urls
)
_before_request = _wrapped_before_request(
_InstrumentedFlask._request_hook,
tracer,
excluded_urls=_InstrumentedFlask._excluded_urls
)
self._before_request = _before_request
self.before_request(_before_request)
_teardown_request = _wrapped_teardown_request(
excluded_urls=_InstrumentedFlask._excluded_urls,
)
self.teardown_request(_teardown_request)
class FlaskInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ("flask >= 1.0",)
def _instrument(self, **kwargs: Any):
logger.info("Flask _instrument entered.")
self._original_flask = flask.Flask
request_hook = kwargs.get("request_hook")
response_hook = kwargs.get("response_hook")
if callable(request_hook):
_InstrumentedFlask._request_hook = request_hook
if callable(response_hook):
_InstrumentedFlask._response_hook = response_hook
tracer_provider = kwargs.get("tracer_provider")
_InstrumentedFlask._tracer_provider = tracer_provider
excluded_urls = kwargs.get("excluded_urls")
_InstrumentedFlask._excluded_urls = (
get_excluded_urls("FLASK")
if excluded_urls is None
else parse_excluded_urls(excluded_urls)
)
flask.Flask = _InstrumentedFlask
logger.info("Flask _instrument exited.")
def _uninstrument(self, **kwargs):
flask.Flask = self._original_flask
def instrument_flask(excluded_urls: str = None,
request_hook: Callable = None,
response_hook: Callable = None,
tracer_provider: TraceProvider = None,
**kwargs: Any,
):
"""
Instrument the Flask application.
Args:
excluded_urls (str): A comma separated list of URLs to be excluded from instrumentation.
request_hook (Callable): A function to be called before a request is processed.
response_hook (Callable): A function to be called after a request is processed.
tracer_provider (TraceProvider): The trace provider to use.
"""
all_kwargs = {
"excluded_urls": excluded_urls,
"request_hook": request_hook,
"response_hook": response_hook,
"tracer_provider": tracer_provider or get_tracer_provider(),
**kwargs
}
FlaskInstrumentor().instrument(**all_kwargs)
logger.info("Flask instrumented.")
@@ -0,0 +1,193 @@
import os
from re import compile as re_compile
from re import search
from typing import Final, Iterable, Any
from urllib.parse import urlparse, urlunparse, unquote
from wsgiref.types import WSGIEnvironment
from requests.models import PreparedRequest
HTTP_REQUEST_METHOD: Final = "http.request.method"
HTTP_FLAVOR: Final = "http.flavor"
HTTP_HOST: Final = "http.host"
HTTP_SCHEME: Final = "http.scheme"
HTTP_USER_AGENT: Final = "http.user_agent"
HTTP_SERVER_NAME: Final = "http.server_name"
SERVER_ADDRESS: Final = "server.address"
SERVER_PORT: Final = "server.port"
URL_PATH: Final = "url.path"
URL_QUERY: Final = "url.query"
CLIENT_ADDRESS: Final = "client.address"
CLIENT_PORT: Final = "client.port"
URL_FULL: Final = "url.full"
HTTP_REQUEST_BODY_SIZE: Final = "http.request.body.size"
HTTP_REQUEST_HEADER: Final = "http.request.header"
HTTP_REQUEST_SIZE: Final = "http.request.size"
HTTP_RESPONSE_BODY_SIZE: Final = "http.response.body.size"
HTTP_RESPONSE_HEADER: Final = "http.response.header"
HTTP_RESPONSE_SIZE: Final = "http.response.size"
HTTP_RESPONSE_STATUS_CODE: Final = "http.response.status_code"
HTTP_ROUTE = "http.route"
def collect_request_attributes(environ: WSGIEnvironment):
attributes: dict[str] = {}
request_method = environ.get("REQUEST_METHOD", "")
request_method = request_method.upper()
attributes[HTTP_REQUEST_METHOD] = request_method
attributes[HTTP_FLAVOR] = environ.get("SERVER_PROTOCOL", "")
attributes[HTTP_SCHEME] = environ.get("wsgi.url_scheme", "")
attributes[HTTP_SERVER_NAME] = environ.get("SERVER_NAME", "")
attributes[HTTP_HOST] = environ.get("HTTP_HOST", "")
host_port = environ.get("SERVER_PORT")
if host_port:
attributes[SERVER_PORT] = host_port
target = environ.get("RAW_URI")
if target is None:
target = environ.get("REQUEST_URI")
if target:
path, query = _parse_url_query(target)
attributes[URL_PATH] = path
attributes[URL_QUERY] = query
remote_addr = environ.get("REMOTE_ADDR", "")
attributes[CLIENT_ADDRESS] = remote_addr
attributes[CLIENT_PORT] = environ.get("REMOTE_PORT", "")
remote_host = environ.get("REMOTE_HOST")
if remote_host and remote_host != remote_addr:
attributes[CLIENT_ADDRESS] = remote_host
attributes[HTTP_USER_AGENT] = environ.get("HTTP_USER_AGENT", "")
return attributes
def collect_attributes_from_request(request: PreparedRequest) -> dict[str]:
attributes: dict[str] = {}
url = remove_url_credentials(request.url)
attributes[HTTP_REQUEST_METHOD] = request.method
attributes[URL_FULL] = url
parsed_url = urlparse(url)
if parsed_url.scheme:
attributes[HTTP_SCHEME] = parsed_url.scheme
if parsed_url.hostname:
attributes[HTTP_HOST] = parsed_url.hostname
if parsed_url.port:
attributes[SERVER_PORT] = parsed_url.port
return attributes
def url_disabled(url: str, excluded_urls: Iterable[str]) -> bool:
"""
Check if the url is disabled.
Args:
url: The url to check.
excluded_urls: The excluded urls.
Returns:
True if the url is disabled, False otherwise.
"""
if excluded_urls is None:
return False
regex = re_compile("|".join(excluded_urls))
return search(regex, url)
def get_excluded_urls(instrumentation: str) -> list[str]:
"""
Get the excluded urls.
Args:
instrumentation: The instrumentation to get the excluded urls for.
Returns:
The excluded urls.
"""
excluded_urls = os.environ.get(f"{instrumentation}_EXCLUDED_URLS")
return parse_excluded_urls(excluded_urls)
def parse_excluded_urls(excluded_urls: str) -> list[str]:
"""
Parse the excluded urls.
Args:
excluded_urls: The excluded urls.
Returns:
The excluded urls.
"""
if excluded_urls:
excluded_url_list = [
excluded_url.strip() for excluded_url in excluded_urls.split(",")
]
else:
excluded_url_list = []
return excluded_url_list
def remove_url_credentials(url: str) -> str:
"""Given a string url, remove the username and password only if it is a valid url"""
try:
parsed = urlparse(url)
if all([parsed.scheme, parsed.netloc]): # checks for valid url
parsed_url = urlparse(url)
_, _, netloc = parsed.netloc.rpartition("@")
return urlunparse(
(
parsed_url.scheme,
netloc,
parsed_url.path,
parsed_url.params,
parsed_url.query,
parsed_url.fragment,
)
)
except ValueError: # an unparsable url was passed
pass
return url
def parser_host_port_url_from_asgi(scope: dict[str, Any]):
"""Returns (host, port, full_url) tuple."""
server = scope.get("server") or ["0.0.0.0", 80]
port = server[1]
server_host = server[0] + (":" + str(port) if str(port) != "80" else "")
full_path = scope.get("path", "")
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
return server_host, port, http_url
def collect_request_attributes_asgi(scope: dict[str, Any]):
attributes: dict[str] = {}
server_host, port, http_url = parser_host_port_url_from_asgi(scope)
query_string = scope.get("query_string")
if query_string and http_url:
if isinstance(query_string, bytes):
query_string = query_string.decode("utf8")
http_url += "?" + unquote(query_string)
attributes[HTTP_REQUEST_METHOD] = scope.get("method", "")
attributes[HTTP_FLAVOR] = scope.get("http_version", "")
attributes[HTTP_SCHEME] = scope.get("scheme", "")
attributes[HTTP_HOST] = server_host
attributes[SERVER_PORT] = port
attributes[URL_FULL] = remove_url_credentials(http_url)
attributes[URL_PATH] = scope.get("path", "")
header = scope.get("headers")
if header:
for key, value in header:
if key == b"user-agent":
attributes[HTTP_USER_AGENT] = value.decode("utf8")
client = scope.get("client")
if client:
attributes[CLIENT_ADDRESS] = client[0]
attributes[CLIENT_PORT] = client[1]
return attributes
def _parse_url_query(url: str):
parsed_url = urlparse(url)
path = parsed_url.path
query_params = parsed_url.query
return path, query_params
@@ -0,0 +1,118 @@
from aworld.metrics.context_manager import MetricContext
from aworld.metrics.template import MetricTemplate
from aworld.metrics.metric import MetricType
tokens_usage_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="llm_token_usage",
unit="token",
description="Measures number of input and output tokens used"
)
chat_choice_counter = MetricTemplate(
type=MetricType.COUNTER,
name="llm_generation_choice_counter",
unit="choice",
description="Number of choices returned by chat completions call"
)
duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="llm_chat_duration",
unit="s",
description="AI chat duration",
)
chat_exception_counter = MetricTemplate(
type=MetricType.COUNTER,
name="llm_chat_exception_counter",
unit="time",
description="Number of exceptions occurred during chat completions",
)
streaming_time_to_first_token_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="llm_streaming_time_to_first_token",
unit="s",
description="Time to first token in streaming chat completions",
)
streaming_time_to_generate_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="streaming_time_to_generate",
unit="s",
description="Time between first token and completion in streaming chat completions",
)
def record_exception_metric(exception, duration):
'''
record chat exception to metrics
'''
if MetricContext.metric_initialized():
labels = {
"error.type": exception.__class__.__name__,
}
if duration_histogram:
MetricContext.histogram_record(
duration_histogram, duration, labels=labels)
if chat_exception_counter:
MetricContext.count(
chat_exception_counter, 1, labels=labels)
def record_streaming_time_to_first_token(duration, labels):
'''
Record duration of start time to first token in stream.
'''
if MetricContext.metric_initialized():
MetricContext.histogram_record(
streaming_time_to_first_token_histogram, duration, labels=labels)
def record_streaming_time_to_generate(first_token_to_generate_duration, labels):
'''
Record duration the first token to response to generation
'''
if MetricContext.metric_initialized():
MetricContext.histogram_record(
streaming_time_to_generate_histogram, first_token_to_generate_duration, labels=labels)
def record_chat_response_metric(attributes,
prompt_tokens,
completion_tokens,
duration,
choices=None
):
'''
Record chat response to metrics
'''
if MetricContext.metric_initialized():
if prompt_tokens and tokens_usage_histogram:
labels = {
**attributes,
"llm.prompt_usage_type": "prompt_tokens"
}
MetricContext.histogram_record(
tokens_usage_histogram, prompt_tokens, labels=labels)
if completion_tokens and tokens_usage_histogram:
labels = {
**attributes,
"llm.prompt_usage_type": "completion_tokens"
}
MetricContext.histogram_record(
tokens_usage_histogram, completion_tokens, labels=labels)
if duration and duration_histogram:
MetricContext.histogram_record(
duration_histogram, duration, labels=attributes)
if choices and chat_choice_counter:
MetricContext.count(chat_choice_counter,
len(choices), labels=attributes)
for choice in choices:
if choice.get("finish_reason"):
finish_reason_attr = {
**attributes,
"llm.finish_reason": choice.get("finish_reason")
}
MetricContext.count(
chat_choice_counter, 1, labels=finish_reason_attr)
@@ -0,0 +1,333 @@
import wrapt
import time
import openai
import traceback
import aworld.trace.instrumentation.semconv as semconv
from typing import Collection, Any, Union
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.base import (
Tracer,
SpanType,
get_tracer_provider_silent
)
from aworld.trace.constants import ATTRIBUTES_MESSAGE_RUN_TYPE_KEY, RunType
from aworld.trace.instrumentation.openai.inout_parse import (
run_async,
handle_openai_request,
is_streaming_response,
record_stream_response_chunk,
parse_openai_response,
record_stream_token_usage,
model_as_dict,
parse_response_message,
)
from aworld.trace.instrumentation.llm_metrics import (
record_exception_metric,
record_chat_response_metric,
record_streaming_time_to_first_token,
record_streaming_time_to_generate
)
from aworld.logs.util import logger
def _chat_wrapper(tracer: Tracer):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
model_name = kwargs.get("model", "")
if not model_name:
model_name = "OpenAI"
span_attributes = {}
span_attributes[ATTRIBUTES_MESSAGE_RUN_TYPE_KEY] = RunType.LLM.value
span = tracer.start_span(
name=model_name, span_type=SpanType.CLIENT, attributes=span_attributes)
run_async(handle_openai_request(span, kwargs, instance))
start_time = time.time()
try:
response = wrapped(*args, **kwargs)
except Exception as e:
record_exception(span=span,
start_time=start_time,
exception=e
)
span.end()
raise e
if is_streaming_response(response):
return WrappedStreamResponse(span=span,
response=response,
instance=instance,
start_time=start_time,
request_kwargs=kwargs
)
record_completion(span=span,
start_time=start_time,
response=response,
request_kwargs=kwargs,
instance=instance
)
span.end()
return response
return wrapper
def _achat_class_wrapper(tracer: Tracer):
async def awrapper(wrapped, instance, args, kwargs):
model_name = kwargs.get("model", "")
if not model_name:
model_name = "OpenAI"
span_attributes = {}
span_attributes[ATTRIBUTES_MESSAGE_RUN_TYPE_KEY] = RunType.LLM.value
span = tracer.start_span(
name=model_name, span_type=SpanType.CLIENT, attributes=span_attributes)
await handle_openai_request(span, kwargs, instance)
start_time = time.time()
try:
response = await wrapped(*args, **kwargs)
except Exception as e:
record_exception(span=span,
start_time=start_time,
exception=e
)
span.end()
raise e
if is_streaming_response(response):
return WrappedStreamResponse(span=span,
response=response,
instance=instance,
start_time=start_time,
request_kwargs=kwargs
)
record_completion(span=span,
start_time=start_time,
response=response,
request_kwargs=kwargs,
instance=instance
)
span.end()
return response
return awrapper
def _achat_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def _awrapper(wrapped, instance, args, kwargs):
wrapper_func = _achat_class_wrapper(tracer)
return await wrapper_func(wrapped, instance, args, kwargs)
return _awrapper
def record_exception(span, start_time, exception):
'''
record openai chat exception to trace and metrics
'''
try:
duration = time.time() - start_time if "start_time" in locals() else 0
if span.is_recording:
span.record_exception(exception=exception)
record_exception_metric(exception=exception, duration=duration)
except Exception as e:
logger.warning(f"openai instrument record exception error.{e}")
def record_completion(span,
start_time,
response,
request_kwargs,
instance):
'''
Record chat completion to trace and metrics
'''
duration = time.time() - start_time if "start_time" in locals() else 0
response_dict = model_as_dict(response)
attributes = parse_openai_response(
response_dict, request_kwargs, instance, False)
usage = response_dict.get("usage")
choices = response_dict.get("choices")
prompt_tokens = usage.get("prompt_tokens")
completion_tokens = usage.get("completion_tokens")
span_attributes = {
**attributes,
semconv.GEN_AI_USAGE_INPUT_TOKENS: prompt_tokens,
semconv.GEN_AI_USAGE_OUTPUT_TOKENS: completion_tokens,
semconv.GEN_AI_DURATION: duration
}
span_attributes.update(parse_response_message(choices))
span.set_attributes(span_attributes)
record_chat_response_metric(attributes=attributes,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
duration=duration,
choices=choices
)
class WrappedStreamResponse(wrapt.ObjectProxy):
def __init__(
self,
span,
response,
instance=None,
start_time=None,
request_kwargs=None
):
super().__init__(response)
self._span = span
self._instance = instance
self._start_time = start_time
self._complete_response = {"choices": [], "model": ""}
self._first_token_recorded = False
self._request_kwargs = request_kwargs
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.__wrapped__.__exit__(exc_type, exc_val, exc_tb)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.__wrapped__.__aexit__(exc_type, exc_val, exc_tb)
def __iter__(self):
return self
def __aiter__(self):
return self
def __next__(self):
try:
chunk = self.__wrapped__.__next__()
except Exception as e:
if isinstance(e, StopIteration):
self._close_span()
raise e
else:
self._process_stream_chunk(chunk)
return chunk
async def __anext__(self):
try:
chunk = await self.__wrapped__.__anext__()
except Exception as e:
if isinstance(e, StopAsyncIteration):
self._close_span()
raise e
else:
self._process_stream_chunk(chunk)
return chunk
def _process_stream_chunk(self, chunk):
record_stream_response_chunk(chunk, self._complete_response)
if not self._first_token_recorded:
self._time_of_first_token = time.time()
duration = self._time_of_first_token - self._start_time
attribute = parse_openai_response(
self._complete_response, self._request_kwargs, self._instance, True)
record_streaming_time_to_first_token(duration, attribute)
self._first_token_recorded = True
def _close_span(self):
duration = None
first_token_duration = None
first_token_to_generate_duration = None
if self._start_time and isinstance(self._start_time, (float, int)):
duration = time.time() - self._start_time
if self._time_of_first_token and self._start_time and isinstance(self._start_time, (float, int)):
first_token_duration = self._time_of_first_token - self._start_time
first_token_to_generate_duration = time.time() - self._time_of_first_token
prompt_usage, completion_usage = record_stream_token_usage(
self._complete_response, self._request_kwargs)
attributes = parse_openai_response(
self._complete_response, self._request_kwargs, self._instance, True)
choices = self._complete_response.get("choices")
span_attributes = {
**attributes,
"llm.prompt_tokens": prompt_usage,
"llm.completion_tokens": completion_usage,
"llm.duration": duration,
"llm.first_token_duration": first_token_duration
}
span_attributes.update(parse_response_message(choices))
self._span.set_attributes(span_attributes)
record_chat_response_metric(attributes=attributes,
prompt_tokens=prompt_usage,
completion_tokens=completion_usage,
duration=duration,
choices=choices
)
record_streaming_time_to_generate(
first_token_to_generate_duration, attributes)
self._span.end()
class OpenAIInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ("openai >= 1.0.0",)
def _instrument(self, **kwargs):
tracer_provider = kwargs.get("tracer_provider")
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.openai")
wrapt.wrap_function_wrapper(
"openai.resources.chat.completions",
"Completions.create",
_chat_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"openai.resources.chat.completions",
"AsyncCompletions.create",
_achat_class_wrapper(tracer)
)
def _instrument(self, **kwargs: Any):
pass
def wrap_openai(client: Union[openai.OpenAI, openai.AsyncOpenAI]):
"""Patch the OpenAI client to make it traceable.
Example:
client = wrap_openai(openai.OpenAI())
"""
try:
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.openai")
if isinstance(client, openai.OpenAI):
wrapper = _chat_wrapper(tracer)
client.chat.completions.create = wrapper(
client.chat.completions.create)
logger.info(
f"[{client.__class__}]client.chat.completions.create be warpped")
if isinstance(client, openai.AsyncOpenAI):
awrapper = _achat_instance_wrapper(tracer)
client.chat.completions.create = awrapper(
client.chat.completions.create)
logger.info(
f"[{client.__class__}]client.chat.completions.create be warpped")
except Exception:
logger.warning(traceback.format_exc())
return client
@@ -0,0 +1,296 @@
import asyncio
import os
import threading
import copy
import json
import openai
from importlib.metadata import version
from aworld.logs.util import logger
from aworld.trace.base import Span
from aworld.utils import import_package
import aworld.trace.instrumentation.semconv as semconv
_PYDANTIC_VERSION = version("pydantic")
def should_trace_prompts():
'''Determine whether it is necessary to record the message
'''
return (os.getenv("SHOULD_TRACE_PROMPTS") or "true").lower() == "true"
def need_flatten_messages():
'''Determine whether it is necessary to flatten the messages
'''
return (os.getenv("TRACE_FLATTEN_MESSAGES") or "false").lower() == "true"
def run_async(method):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
thread = threading.Thread(target=lambda: asyncio.run(method))
thread.start()
thread.join()
else:
asyncio.run(method)
async def handle_openai_request(span: Span, kwargs, instance):
if not span or not span.is_recording():
return
try:
attributes = parser_request_params(kwargs, instance)
if should_trace_prompts():
messages = kwargs.get("messages")
if need_flatten_messages():
attributes.update(parse_request_message(messages))
else:
attributes.update({
semconv.GEN_AI_PROMPT: str(messages),
})
span.set_attributes(attributes)
except ValueError as e:
logger.warning(f"trace handle openai request error: {e}")
def parser_request_params(kwargs, instance):
attributes = {
semconv.GEN_AI_SYSTEM: "OpenAI",
semconv.GEN_AI_REQUEST_MODEL: kwargs.get("model", ""),
semconv.GEN_AI_REQUEST_MAX_TOKENS: kwargs.get("max_tokens", ""),
semconv.GEN_AI_REQUEST_TEMPERATURE: kwargs.get("temperature", ""),
semconv.GEN_AI_REQUEST_TOP_P: kwargs.get("top_p", ""),
semconv.GEN_AI_REQUEST_FREQUENCY_PENALTY: kwargs.get("frequency_penalty", ""),
semconv.GEN_AI_REQUEST_PRESENCE_PENALTY: kwargs.get("presence_penalty", ""),
semconv.GEN_AI_REQUEST_USER: kwargs.get("user", ""),
semconv.GEN_AI_REQUEST_EXTRA_HEADERS: kwargs.get("extra_headers", ""),
semconv.GEN_AI_REQUEST_STREAMING: kwargs.get("stream", ""),
semconv.GEN_AI_OPERATION_NAME: "chat"
}
client = instance._client
if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)):
attributes.update({"llm.base_url": str(client.base_url)})
filterd_attri = {k: v for k, v in attributes.items()
if (v and v != "")}
return filterd_attri
def is_streaming_response(response):
return isinstance(response, openai.Stream) or isinstance(response, openai.AsyncStream)
def parse_openai_response(response, request_kwargs, instance, is_streaming):
return {
semconv.GEN_AI_RESPONSE_MODEL: response.get("model") or request_kwargs.get("model") or None,
semconv.GEN_AI_SERVER_ADDRESS: _get_openai_base_url(instance)
}
def record_stream_token_usage(complete_response, request_kwargs) -> tuple[int, int]:
'''
return (prompt_usage, completion_usage)
'''
prompt_usage = 0
completion_usage = 0
# prompt_usage
if request_kwargs and request_kwargs.get("messages"):
prompt_content = ""
model_name = complete_response.get(
"model") or request_kwargs.get("model") or "gpt-4"
for msg in request_kwargs.get("messages"):
if msg.get("content"):
prompt_content += msg.get("content")
if model_name:
prompt_usage = get_token_count_from_string(
prompt_content, model_name)
# completion_usage
if complete_response.get("choices"):
completion_content = ""
model_name = complete_response.get("model") or "gpt-4"
for choice in complete_response.get("choices"):
if choice.get("message") and choice.get("message").get("content"):
completion_content += choice["message"]["content"]
if model_name:
completion_usage = get_token_count_from_string(
completion_content, model_name)
return (prompt_usage, completion_usage)
def _get_openai_base_url(instance):
if hasattr(instance, "_client"):
client = instance._client # pylint: disable=protected-access
if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)):
return str(client.base_url)
return ""
def get_token_count_from_string(string: str, model_name: str):
import_package("tiktoken")
import tiktoken
if tiktoken_encodings.get(model_name) is None:
try:
encoding = tiktoken.encoding_for_model(model_name)
except KeyError as ex:
logger.warning(
f"Failed to get tiktoken encoding for model_name {model_name}, error: {str(ex)}")
return None
tiktoken_encodings[model_name] = encoding
else:
encoding = tiktoken_encodings.get(model_name)
token_count = len(encoding.encode(string))
return token_count
def record_stream_response_chunk(chunk, complete_response):
chunk = model_as_dict(chunk)
complete_response["model"] = chunk.get("model")
complete_response["id"] = chunk.get("id")
# prompt filter results
if chunk.get("prompt_filter_results"):
complete_response["prompt_filter_results"] = chunk.get(
"prompt_filter_results")
for choice in chunk.get("choices"):
index = choice.get("index")
if len(complete_response.get("choices")) <= index:
complete_response["choices"].append(
{"index": index, "message": {"content": "", "role": ""}})
complete_choice = complete_response.get("choices")[index]
if choice.get("finish_reason"):
complete_choice["finish_reason"] = choice.get("finish_reason")
if choice.get("content_filter_results"):
complete_choice["content_filter_results"] = choice.get(
"content_filter_results")
delta = choice.get("delta")
if delta and delta.get("content"):
complete_choice["message"]["content"] += delta.get("content")
if delta and delta.get("role"):
complete_choice["message"]["role"] = delta.get("role")
if delta and delta.get("tool_calls"):
tool_calls = delta.get("tool_calls")
if not isinstance(tool_calls, list) or len(tool_calls) == 0:
continue
if not complete_choice["message"].get("tool_calls"):
complete_choice["message"]["tool_calls"] = []
for tool_call in tool_calls:
i = int(tool_call["index"])
if len(complete_choice["message"]["tool_calls"]) <= i:
complete_choice["message"]["tool_calls"].append(
{"id": "", "function": {"name": "", "arguments": ""}}
)
span_tool_call = complete_choice["message"]["tool_calls"][i]
span_function = span_tool_call["function"]
tool_call_function = tool_call.get("function")
if tool_call.get("id"):
span_tool_call["id"] = tool_call.get("id")
if tool_call_function and tool_call_function.get("name"):
span_function["name"] = tool_call_function.get("name")
if tool_call_function and tool_call_function.get("arguments"):
span_function["arguments"] += tool_call_function.get(
"arguments")
def parse_request_message(messages):
'''
flatten request message to attributes
'''
attributes = {}
for i, msg in enumerate(messages):
prefix = f"{semconv.GEN_AI_PROMPT}.{i}"
attributes.update({f"{prefix}.role": msg.get("role")})
if msg.get("content"):
content = copy.deepcopy(msg.get("content"))
content = json.dumps(content)
attributes.update({f"{prefix}.content": content})
if msg.get("tool_call_id"):
attributes.update({
f"{prefix}.tool_call_id": msg.get("tool_call_id")})
tool_calls = msg.get("tool_calls")
if tool_calls:
for i, tool_call in enumerate(tool_calls):
tool_call = model_as_dict(tool_call)
function = tool_call.get("function")
attributes.update({
f"{prefix}.tool_calls.{i}.id": tool_call.get("id")})
attributes.update({
f"{prefix}.tool_calls.{i}.name": function.get("name")})
attributes.update({
f"{prefix}.tool_calls.{i}.arguments": function.get("arguments")})
return attributes
def parse_response_message(choices) -> dict:
attributes = {}
if not should_trace_prompts():
return attributes
for choice in choices:
index = choice.get("index")
prefix = f"{semconv.GEN_AI_COMPLETION}.{index}"
attributes.update(
{f"{prefix}.finish_reason": choice.get("finish_reason")})
message = choice.get("message")
if not message:
continue
attributes.update({f"{prefix}.role": message.get("role")})
if message.get("refusal"):
attributes.update({f"{prefix}.refusal": message.get("refusal")})
else:
attributes.update({f"{prefix}.content": message.get("content")})
function_call = message.get("function_call")
if function_call:
attributes.update(
{f"{prefix}.tool_calls.0.name": function_call.get("name")})
attributes.update(
{f"{prefix}.tool_calls.0.arguments": function_call.get("arguments")})
tool_calls = message.get("tool_calls")
if tool_calls:
for i, tool_call in enumerate(tool_calls):
function = tool_call.get("function")
attributes.update(
{f"{prefix}.tool_calls.{i}.id": tool_call.get("id")})
attributes.update(
{f"{prefix}.tool_calls.{i}.name": function.get("name")})
attributes.update(
{f"{prefix}.tool_calls.{i}.arguments": function.get("arguments")})
return attributes
def model_as_dict(model):
if isinstance(model, dict):
return model
if _PYDANTIC_VERSION < "2.0.0":
return model.dict()
if hasattr(model, "model_dump"):
return model.model_dump()
elif hasattr(model, "parse"): # Raw API response
return model_as_dict(model.parse())
else:
return model
@@ -0,0 +1,213 @@
from aworld.logs.util import logger
from aworld.trace.instrumentation.http_util import (
collect_attributes_from_request,
url_disabled,
get_excluded_urls,
parse_excluded_urls,
HTTP_RESPONSE_STATUS_CODE,
HTTP_FLAVOR
)
from aworld.metrics.context_manager import MetricContext
from aworld.metrics.template import MetricTemplate
from aworld.metrics.metric import MetricType
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.propagator.carrier import DictCarrier
import functools
from timeit import default_timer
from requests import sessions
from requests.models import PreparedRequest, Response
from requests.structures import CaseInsensitiveDict
from typing import Collection, Any, Callable
from aworld.trace.base import TraceProvider, TraceContext, Tracer, SpanType, get_tracer_provider
from aworld.trace.propagator import get_global_trace_propagator
def _wrapped_send(
tracer: Tracer = None,
excluded_urls=None,
request_hook: Callable = None,
response_hook: Callable = None,
duration_histogram: MetricTemplate = None
):
oringinal_send = sessions.Session.send
@functools.wraps(oringinal_send)
def instrumented_send(
self: sessions.Session, request: PreparedRequest, **kwargs: Any
):
if excluded_urls and url_disabled(request.url, excluded_urls):
return oringinal_send(self, request, **kwargs)
def get_or_create_headers():
request.headers = (
request.headers
if request.headers is not None
else CaseInsensitiveDict()
)
return request.headers
method = request.method
if method is None:
method = "HTTP"
span_name = method.upper()
span_attributes = collect_attributes_from_request(request)
with tracer.start_as_current_span(
span_name, span_type=SpanType.CLIENT, attributes=span_attributes
) as span:
exception = None
if callable(request_hook):
request_hook(span, request)
headers = get_or_create_headers()
trace_context = TraceContext(
trace_id=span.get_trace_id(),
span_id=span.get_span_id(),
)
propagator = get_global_trace_propagator()
if propagator:
propagator.inject(trace_context, DictCarrier(headers))
start_time = default_timer()
try:
logger.info("Sending headers: %s", request.headers)
result = oringinal_send(
self, request, **kwargs
) # *** PROCEED
except Exception as exc: # pylint: disable=W0703
exception = exc
result = getattr(exc, "response", None)
finally:
elapsed_time = max(default_timer() - start_time, 0)
if isinstance(result, Response):
span_attributes = {}
span_attributes[HTTP_RESPONSE_STATUS_CODE] = result.status_code
if result.raw is not None:
version = getattr(result.raw, "version", None)
if version:
# Only HTTP/1 is supported by requests
version_text = "1.1" if version == 11 else "1.0"
span_attributes[HTTP_FLAVOR] = version_text
span.set_attributes(span_attributes)
if callable(response_hook):
response_hook(span, request, result)
if exception is not None:
span.record_exception(exception)
if duration_histogram is not None and MetricContext.metric_initialized():
MetricContext.histogram_record(
duration_histogram,
elapsed_time,
span_attributes
)
if exception is not None:
raise exception.with_traceback(exception.__traceback__)
return result
return instrumented_send
class _InstrumentedSession(sessions.Session):
"""
An instrumented requests.Session class.
"""
_excluded_urls = None
_tracer_provider: TraceProvider = None
_request_hook = None
_response_hook = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tracer = self._tracer_provider.get_tracer(
"aworld.trace.instrumentation.requests")
excluded_urls = kwargs.get("excluded_urls")
duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="client_request_duration_histogram",
unit="s",
description="Duration of HTTP client requests."
)
self.send = functools.partial(_wrapped_send(
tracer=tracer,
excluded_urls=excluded_urls,
request_hook=self._request_hook,
response_hook=self._response_hook,
duration_histogram=duration_histogram
), self)
class RequestsInstrumentor(Instrumentor):
"""
An instrumentor for the requests module.
"""
def instrumentation_dependencies(self) -> Collection[str]:
return ["requests"]
def _instrument(self, **kwargs):
"""
Instruments the requests module.
"""
logger.info("requests _instrument entered.")
self._original_session = sessions.Session
request_hook = kwargs.get("request_hook")
response_hook = kwargs.get("response_hook")
if callable(request_hook):
_InstrumentedSession._request_hook = request_hook
if callable(response_hook):
_InstrumentedSession._response_hook = response_hook
tracer_provider = kwargs.get("tracer_provider")
_InstrumentedSession._tracer_provider = tracer_provider
excluded_urls = kwargs.get("excluded_urls")
_InstrumentedSession._excluded_urls = (
get_excluded_urls("FLASK")
if excluded_urls is None
else parse_excluded_urls(excluded_urls)
)
sessions.Session = _InstrumentedSession
logger.info("requests _instrument exited.")
def _uninstrument(self, **kwargs):
"""
Uninstruments the requests module.
"""
sessions.Session = self._original_session
def instrument_requests(excluded_urls: str = None,
request_hook: Callable = None,
response_hook: Callable = None,
tracer_provider: TraceProvider = None,
**kwargs: Any,
):
"""
Instruments the requests module.
Args:
excluded_urls: A comma separated list of URLs to exclude from tracing.
request_hook: A function that will be called before a request is sent.
The function will be called with the span and the request.
response_hook: A function that will be called after a response is received.
The function will be called with the span and the response.
tracer_provider: The tracer provider to use. If not provided, the global
tracer provider will be used.
kwargs: Additional keyword arguments.
"""
all_kwargs = {
"excluded_urls": excluded_urls,
"request_hook": request_hook,
"response_hook": response_hook,
"tracer_provider": tracer_provider or get_tracer_provider(),
**kwargs
}
RequestsInstrumentor().instrument(**all_kwargs)
logger.info("Requests instrumented.")
@@ -0,0 +1,46 @@
# GenAI semconv attribute names
import os
GEN_AI_SYSTEM = "gen_ai.system"
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
GEN_AI_REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences"
GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k"
GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"
GEN_AI_REQUEST_STREAMING = "gen_ai.request.streaming"
GEN_AI_REQUEST_USER = "gen_ai.request.user"
GEN_AI_REQUEST_EXTRA_HEADERS = "gen_ai.request.extra_headers"
GEN_AI_PROMPT = "gen_ai.prompt"
GEN_AI_PROMPT_TOOLS = "gen_ai.prompt.tools"
GEN_AI_COMPLETION = "gen_ai.completion"
GEN_AI_COMPLETION_TOOL_CALLS = "gen_ai.completion.tool_calls"
GEN_AI_COMPLETION_CONTENT = "gen_ai.completion.content"
GEN_AI_DURATION = "gen_ai.duration"
GEN_AI_FIRST_TOKEN_DURATION = "gen_ai.first_token_duration"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GEN_AI_RESPONSE_ID = "gen_ai.response.id"
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
GEN_AI_METHOD_NAME = "gen_ai.method.name"
GEN_AI_SERVER_ADDRESS = "gen_ai.server.address"
ATTRIBUTE_NAME_SPACE = os.getenv("ATTRIBUTE_NAME_SPACE", "aworld.")
AGENT_ID = ATTRIBUTE_NAME_SPACE + "agent.id"
AGENT_NAME = ATTRIBUTE_NAME_SPACE + "agent.name"
AGENT_RUN_SUCCESS = ATTRIBUTE_NAME_SPACE + "agent.run.success"
AGENT_USAGE_TYPE = ATTRIBUTE_NAME_SPACE + "agent.usage_type"
TOOL_NAME = ATTRIBUTE_NAME_SPACE + "tool.name"
TOOL_STEP_SUCCESS = ATTRIBUTE_NAME_SPACE + "tool.step.success"
TASK = ATTRIBUTE_NAME_SPACE + "task"
TASK_ID = ATTRIBUTE_NAME_SPACE + "task.id"
TASK_INPUT = ATTRIBUTE_NAME_SPACE + "task.input"
TASK_IS_SUB_TASK = ATTRIBUTE_NAME_SPACE + "task.is_sub_task"
TASK_GROUP_ID = ATTRIBUTE_NAME_SPACE + "task.group_id"
SESSION_ID = ATTRIBUTE_NAME_SPACE + "session.id"
USER_ID = ATTRIBUTE_NAME_SPACE + "user.id"
@@ -0,0 +1,149 @@
import threading
from typing import Protocol, TypeVar, Any, Callable
from wrapt import wrap_function_wrapper
from concurrent import futures
import aworld.trace as trace
from aworld.trace.base import TraceContext, Span
from aworld.trace.propagator import get_global_trace_context
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.instrumentation.utils import unwrap
from aworld.logs.util import logger
R = TypeVar("R")
class HasTraceContext(Protocol):
_trace_context: TraceContext
class ThreadingInstrumentor(Instrumentor):
'''
Trace instrumentor for threading
'''
def instrumentation_dependencies(self) -> str:
return ()
def _instrument(self, **kwargs: Any):
self._instrument_thread()
self._instrument_timer()
self._instrument_thread_pool()
def _uninstrument(self, **kwargs: Any):
self._uninstrument_thread()
self._uninstrument_timer()
self._uninstrument_thread_pool()
@staticmethod
def _instrument_thread():
wrap_function_wrapper(
threading.Thread,
"start",
ThreadingInstrumentor.__wrap_threading_start,
)
wrap_function_wrapper(
threading.Thread,
"run",
ThreadingInstrumentor.__wrap_threading_run,
)
@staticmethod
def _instrument_timer():
wrap_function_wrapper(
threading.Timer,
"start",
ThreadingInstrumentor.__wrap_threading_start,
)
wrap_function_wrapper(
threading.Timer,
"run",
ThreadingInstrumentor.__wrap_threading_run,
)
@staticmethod
def _instrument_thread_pool():
wrap_function_wrapper(
futures.ThreadPoolExecutor,
"submit",
ThreadingInstrumentor.__wrap_thread_pool_submit,
)
@staticmethod
def _uninstrument_thread():
unwrap(threading.Thread, "start")
unwrap(threading.Thread, "run")
@staticmethod
def _uninstrument_timer():
unwrap(threading.Timer, "start")
unwrap(threading.Timer, "run")
@staticmethod
def _uninstrument_thread_pool():
unwrap(futures.ThreadPoolExecutor, "submit")
@staticmethod
def __wrap_threading_start(
call_wrapped: Callable[[], None],
instance: HasTraceContext,
args: tuple[()],
kwargs: dict[str, Any],
) -> None:
span: Span = trace.get_current_span()
if span:
instance._trace_context = TraceContext(
trace_id=span.get_trace_id(), span_id=span.get_span_id())
return call_wrapped(*args, **kwargs)
@staticmethod
def __wrap_threading_run(
call_wrapped: Callable[..., R],
instance: HasTraceContext,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> R:
token = None
try:
if hasattr(instance, "_trace_context"):
if instance._trace_context:
token = get_global_trace_context().set(instance._trace_context)
return call_wrapped(*args, **kwargs)
finally:
if token:
get_global_trace_context().reset(token)
@staticmethod
def __wrap_thread_pool_submit(
call_wrapped: Callable[..., R],
instance: futures.ThreadPoolExecutor,
args: tuple[Callable[..., Any], ...],
kwargs: dict[str, Any],
) -> R:
# obtain the original function and wrapped kwargs
original_func = args[0]
trace_context = None
span: Span = trace.get_current_span()
if span and span.get_trace_id() != "":
trace_context = TraceContext(
trace_id=span.get_trace_id(), span_id=span.get_span_id())
def wrapped_func(*func_args: Any, **func_kwargs: Any) -> R:
token = None
try:
if trace_context:
token = get_global_trace_context().set(trace_context)
return original_func(*func_args, **func_kwargs)
finally:
if token:
get_global_trace_context().reset(token)
# replace the original function with the wrapped function
new_args: tuple[Callable[..., Any], ...] = (wrapped_func,) + args[1:]
return call_wrapped(*new_args, **kwargs)
def instrument_theading(**kwargs: Any) -> None:
ThreadingInstrumentor().instrument(**kwargs)
logger.info("Threading instrumented")
@@ -0,0 +1,249 @@
from asyncio import iscoroutine
import wrapt
import time
import traceback
import aworld.trace.constants as trace_constants
from typing import Collection, Any, Union, Sequence
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.instrumentation import semconv
from aworld.trace.base import (
Tracer,
SpanType,
get_tracer_provider_silent
)
from aworld.logs.util import logger
from aworld.metrics.context_manager import MetricContext
from aworld.metrics.template import MetricTemplate
from aworld.metrics.metric import MetricType
tool_duration_histogram = MetricTemplate(
type=MetricType.HISTOGRAM,
name="tool_step_duration",
unit="s",
description="tool step run duration",
)
tool_step_counter = MetricTemplate(
type=MetricType.COUNTER,
name="tool_step_counter",
unit="time",
description="Number of tool step run",
)
def get_tool_name(tool_name: str,
action: Union['ActionModel', Sequence['ActionModel']]) -> tuple[str, trace_constants.RunType]:
if tool_name == "mcp" and action:
try:
if isinstance(action, (list, tuple)):
action = action[0]
mcp_name = action.action_name.split("__")[0]
return (mcp_name, trace_constants.RunType.MCP)
except ValueError:
logger.warning(traceback.format_exc())
return (tool_name, trace_constants.RunType.MCP)
return (tool_name, trace_constants.RunType.TOOL)
def get_tool_span_attributes(instance, message: 'Message'):
run_type = trace_constants.RunType.TOOL
action = message.payload
agent_id = None
tool_name = instance.name()
if isinstance(action, (list, tuple)):
action = action[0]
if action:
agent_id = action.agent_name
tool_name, run_type = get_tool_name(action.tool_name, action)
return {
semconv.TOOL_NAME: tool_name,
semconv.AGENT_ID: agent_id,
semconv.AGENT_NAME: _get_agent_name_from_id(agent_id),
semconv.TASK_ID: message.context.task_id if (message.context and message.context.task_id) else "",
semconv.SESSION_ID: message.context.session_id if (message.context and message.context.session_id) else "",
semconv.USER_ID: message.context.user if (message.context and message.context.user) else "",
trace_constants.ATTRIBUTES_MESSAGE_RUN_TYPE_KEY: run_type.value
}
def _end_span(span):
if span:
span.end()
def _get_agent_name_from_id(agent_id):
if agent_id and '---' in agent_id:
return agent_id.split('---', 1)[0]
return agent_id
def _record_metric(duration, attributes, exception=None):
if MetricContext.metric_initialized():
MetricContext.histogram_record(tool_duration_histogram, duration, labels=attributes)
if exception:
run_counter_attr = {
semconv.TOOL_STEP_SUCCESS: "0",
"error.type": exception.__class__.__name__,
**attributes
}
else:
run_counter_attr = {
semconv.TOOL_STEP_SUCCESS: "1",
**attributes
}
MetricContext.count(tool_step_counter, 1, labels=run_counter_attr)
def _record_exception(span, start_time, exception, attributes):
try:
duration = time.time() - start_time if "start_time" in locals() else 0
if span.is_recording:
span.record_exception(exception=exception)
_record_metric(duration, attributes, exception)
except Exception as e:
logger.warning(f"tool instrument record exception error.{e}")
def _record_response(instance,
start_time,
response,
attributes):
try:
duration = time.time() - start_time if "start_time" in locals() else 0
_record_metric(duration, attributes)
except Exception as e:
logger.warning(f"tool instrument record response error.{e}")
def _async_step_class_wrapper(tracer: Tracer):
async def _async_step_wrapper(wrapped, instance, args, kwargs):
span = None
message = args[0] or kwargs.get("message")
attributes = get_tool_span_attributes(instance, message)
if tracer:
span = tracer.start_span(
name=trace_constants.SPAN_NAME_PREFIX_TOOL + "step",
span_type=SpanType.SERVER,
attributes=attributes
)
start_time = time.time()
try:
response = await wrapped(*args, **kwargs)
_record_response(instance, start_time, response, attributes)
except Exception as e:
_record_exception(span=span,
start_time=start_time,
exception=e,
attributes=attributes
)
_end_span(span)
raise e
_end_span(span)
return response
return _async_step_wrapper
def _step_class_wrapper(tracer: Tracer):
def _step_wrapper(wrapped, instance, args, kwargs):
span = None
message = args[0] or kwargs.get("message")
attributes = get_tool_span_attributes(instance, message)
if tracer:
span = tracer.start_span(
name=trace_constants.SPAN_NAME_PREFIX_TOOL + "step",
span_type=SpanType.SERVER,
attributes=attributes
)
start_time = time.time()
try:
response = wrapped(*args, **kwargs)
_record_response(instance, start_time, response, attributes)
except Exception as e:
_record_exception(span=span,
start_time=start_time,
exception=e,
attributes=attributes
)
_end_span(span)
raise e
_end_span(span)
return response
return _step_wrapper
async def _async_step_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def _awrapper(wrapped, instance, args, kwargs):
wrapper_func = _async_step_class_wrapper(tracer=tracer)
return await wrapper_func(wrapped, instance, args, kwargs)
return _awrapper
def _step_instance_wrapper(tracer: Tracer):
@wrapt.decorator
def _wrapper(wrapped, instance, args, kwargs):
wrapper_func = _step_class_wrapper(tracer)
return wrapper_func(wrapped, instance, args, kwargs)
return _wrapper
class ToolInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ()
def _instrument(self, **kwargs):
agent_trace_enabled = kwargs.get("trace_enabled", False)
tracer_provider = get_tracer_provider_silent()
tracer = None
if tracer_provider and agent_trace_enabled:
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.tool")
wrapt.wrap_function_wrapper(
"aworld.core.tool.base",
"AsyncBaseTool.step",
_async_step_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.core.tool.base",
"AsyncTool.step",
_async_step_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.core.tool.base",
"BaseTool.step",
_step_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.core.tool.base",
"Tool.step",
_step_class_wrapper(tracer=tracer)
)
def _uninstrument(self, **kwargs: Any):
pass
def wrap_tool(tool):
try:
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return tool
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.tool")
async_wrapper = _async_step_instance_wrapper(tracer)
wrapper = _step_instance_wrapper(tracer)
if iscoroutine(tool.step):
tool.step = async_wrapper(tool.step)
else:
tool.step = wrapper(tool.step)
except Exception:
logger.warning(traceback.format_exc())
return tool
@@ -0,0 +1,379 @@
import wrapt
import time
import traceback
import aworld.trace.instrumentation.semconv as semconv
from typing import Collection, Any
from aworld.trace.instrumentation import Instrumentor
from aworld.trace.base import (
Tracer,
SpanType,
get_tracer_provider_silent
)
from aworld.trace.constants import ATTRIBUTES_MESSAGE_RUN_TYPE_KEY, RunType, SPAN_NAME_PREFIX_LLM
from aworld.trace.instrumentation.llm_metrics import (
record_exception_metric,
record_chat_response_metric,
record_streaming_time_to_first_token,
record_streaming_time_to_generate
)
from aworld.trace.instrumentation.uni_llmmodel.model_response_parse import (
accumulate_stream_response,
get_common_attributes_from_response,
record_stream_token_usage,
parse_response_message,
response_to_dic,
handle_request
)
from aworld.trace.instrumentation.openai.inout_parse import run_async
from aworld.models.model_response import ModelResponse
from aworld.logs.util import logger
def _completion_class_wrapper(tracer: Tracer):
def wrapper(wrapped, instance, args, kwargs):
model_name = instance.provider.model_name
if not model_name:
model_name = "LLMModel"
span_attributes = {}
span_attributes[ATTRIBUTES_MESSAGE_RUN_TYPE_KEY] = RunType.LLM.value
span = tracer.start_span(
name=SPAN_NAME_PREFIX_LLM + model_name, span_type=SpanType.CLIENT, attributes=span_attributes)
run_async(handle_request(span, kwargs, instance))
start_time = time.time()
try:
response = wrapped(*args, **kwargs)
except Exception as e:
record_exception(span=span,
start_time=start_time,
exception=e
)
span.end()
raise e
record_completion(span=span,
start_time=start_time,
response=response,
request_kwargs=kwargs,
instance=instance,
is_async=False
)
span.end()
return response
return wrapper
def _completion_instance_wrapper(tracer: Tracer):
@wrapt.decorator
def _wrapper(wrapped, instance, args, kwargs):
wrapper_func = _completion_class_wrapper(tracer)
return wrapper_func(wrapped, instance, args, kwargs)
return _wrapper
def _stream_completion_class_wrapper(tracer: Tracer):
def wrapper(wrapped, instance, args, kwargs):
model_name = instance.provider.model_name
if not model_name:
model_name = "LLMModel"
span_attributes = {}
span_attributes[ATTRIBUTES_MESSAGE_RUN_TYPE_KEY] = RunType.LLM.value
span = tracer.start_span(
name=SPAN_NAME_PREFIX_LLM + model_name, span_type=SpanType.CLIENT, attributes=span_attributes)
run_async(handle_request(span, kwargs, instance))
start_time = time.time()
try:
response = wrapped(*args, **kwargs)
except Exception as e:
record_exception(span=span,
start_time=start_time,
exception=e
)
span.end()
raise e
return WrappedGeneratorResponse(span=span,
response=response,
instance=instance,
start_time=start_time,
request_kwargs=kwargs
)
return wrapper
def _stream_completion_instance_wrapper(tracer: Tracer):
@wrapt.decorator
def _stream_wrapper(wrapped, instance, args, kwargs):
wrapper_func = _stream_completion_class_wrapper(tracer)
return wrapper_func(wrapped, instance, args, kwargs)
return _stream_wrapper
def _acompletion_class_wrapper(tracer: Tracer):
async def awrapper(wrapped, instance, args, kwargs):
model_name = instance.provider.model_name
if not model_name:
model_name = "LLMModel"
span_attributes = {}
span_attributes[ATTRIBUTES_MESSAGE_RUN_TYPE_KEY] = RunType.LLM.value
span = tracer.start_span(
name=SPAN_NAME_PREFIX_LLM + model_name, span_type=SpanType.CLIENT, attributes=span_attributes)
await handle_request(span, kwargs, instance)
start_time = time.time()
try:
response = await wrapped(*args, **kwargs)
except Exception as e:
record_exception(span=span,
start_time=start_time,
exception=e
)
span.end()
raise e
record_completion(span=span,
start_time=start_time,
response=response,
request_kwargs=kwargs,
instance=instance,
is_async=True
)
span.end()
return response
return awrapper
async def _acompletion_instance_wrapper(tracer: Tracer):
@wrapt.decorator
async def _awrapper(wrapped, instance, args, kwargs):
wrapper_func = _acompletion_class_wrapper(tracer)
return await wrapper_func(wrapped, instance, args, kwargs)
return _awrapper
def record_exception(span, start_time, exception):
'''
record openai chat exception to trace and metrics
'''
try:
duration = time.time() - start_time if "start_time" in locals() else 0
if span.is_recording:
span.record_exception(exception=exception)
record_exception_metric(exception=exception, duration=duration)
except Exception as e:
logger.warning(f"openai instrument record exception error.{e}")
def record_completion(span,
start_time,
response,
request_kwargs,
instance,
is_async):
'''
Record chat completion to trace and metrics
'''
duration = time.time() - start_time if "start_time" in locals() else 0
response_dict = response_to_dic(response)
attributes = get_common_attributes_from_response(instance, is_async, False)
usage = response_dict.get("usage")
content = response_dict.get("content", "")
tool_calls = response_dict.get("tool_calls")
prompt_tokens = -1
completion_tokens = -1
total_tokens = -1
if usage:
prompt_tokens = usage.get("prompt_tokens")
completion_tokens = usage.get("completion_tokens")
total_tokens = usage.get("total_tokens")
span_attributes = {
**attributes,
semconv.GEN_AI_USAGE_INPUT_TOKENS: prompt_tokens,
semconv.GEN_AI_USAGE_OUTPUT_TOKENS: completion_tokens,
semconv.GEN_AI_USAGE_TOTAL_TOKENS: total_tokens,
semconv.GEN_AI_DURATION: duration,
semconv.GEN_AI_COMPLETION_CONTENT: content
}
span_attributes.update(parse_response_message(tool_calls))
span.set_attributes(span_attributes)
record_chat_response_metric(attributes=attributes,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
duration=duration
)
class WrappedGeneratorResponse():
def __init__(
self,
span,
response,
instance=None,
start_time=None,
request_kwargs=None
):
self._span = span
self._response = response
self._instance = instance
self._start_time = start_time
self._complete_response = {
"id": "", "model": "", "content": "", "tool_calls": [], "usage": {}}
self._first_token_recorded = False
self._time_of_first_token = None
self._request_kwargs = request_kwargs
def __iter__(self):
return self
def __aiter__(self):
return self
def __next__(self):
try:
chunk = self._response.__next__()
except Exception as e:
if isinstance(e, StopIteration):
self._close_span(False)
raise e
else:
self._process_stream_chunk(chunk, False)
return chunk
async def __anext__(self):
try:
chunk = await self._response.__anext__()
except Exception as e:
if isinstance(e, StopAsyncIteration):
self._close_span(True)
raise e
else:
self._process_stream_chunk(chunk, True)
return chunk
def _process_stream_chunk(self, chunk: ModelResponse, is_async):
accumulate_stream_response(chunk, self._complete_response)
if not self._first_token_recorded:
self._time_of_first_token = time.time()
duration = self._time_of_first_token - self._start_time
attribute = get_common_attributes_from_response(
self._instance, is_async, True)
record_streaming_time_to_first_token(duration, attribute)
self._first_token_recorded = True
def _close_span(self, is_async):
duration = None
first_token_duration = None
first_token_to_generate_duration = None
if self._start_time and isinstance(self._start_time, (float, int)):
duration = time.time() - self._start_time
if self._time_of_first_token and self._start_time and isinstance(self._start_time, (float, int)):
first_token_duration = self._time_of_first_token - self._start_time
first_token_to_generate_duration = time.time() - self._time_of_first_token
prompt_usage, completion_usage = record_stream_token_usage(
self._complete_response, self._request_kwargs)
attributes = get_common_attributes_from_response(
self._instance, is_async, True)
span_attributes = {
**attributes,
semconv.GEN_AI_USAGE_INPUT_TOKENS: prompt_usage,
semconv.GEN_AI_USAGE_OUTPUT_TOKENS: completion_usage,
semconv.GEN_AI_USAGE_TOTAL_TOKENS: prompt_usage + completion_usage,
semconv.GEN_AI_DURATION: duration,
semconv.GEN_AI_FIRST_TOKEN_DURATION: first_token_duration,
semconv.GEN_AI_COMPLETION_CONTENT: self._complete_response.get(
"content", "")
}
span_attributes.update(parse_response_message(
self._complete_response.get("tool_calls", [])))
self._span.set_attributes(span_attributes)
record_chat_response_metric(attributes=attributes,
prompt_tokens=prompt_usage,
completion_tokens=completion_usage,
duration=duration
)
record_streaming_time_to_generate(
first_token_to_generate_duration, attributes)
self._span.end()
class LLMModelInstrumentor(Instrumentor):
def instrumentation_dependencies(self) -> Collection[str]:
return ()
def _instrument(self, **kwargs):
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.llmmodel")
wrapt.wrap_function_wrapper(
"aworld.models.llm",
"LLMModel.completion",
_completion_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.models.llm",
"LLMModel.stream_completion",
_stream_completion_class_wrapper(tracer=tracer)
)
wrapt.wrap_function_wrapper(
"aworld.models.llm",
"LLMModel.acompletion",
_acompletion_class_wrapper(tracer)
)
wrapt.wrap_function_wrapper(
"aworld.models.llm",
"LLMModel.astream_completion",
_stream_completion_class_wrapper(tracer)
)
logger.info(f"LLMModelInstrumentor wrap aworld.models.llm")
def _uninstrument(self, **kwargs: Any):
pass
def wrap_llmmodel(client: 'aworld.models.llm.LLMModel'):
try:
tracer_provider = get_tracer_provider_silent()
if not tracer_provider:
return client
tracer = tracer_provider.get_tracer(
"aworld.trace.instrumentation.llmmodel")
wrapper = _completion_instance_wrapper(tracer)
awrapper = _acompletion_instance_wrapper(tracer)
stream_wrapper = _stream_completion_instance_wrapper(tracer)
client.completion = wrapper(client.completion)
client.stream_completion = stream_wrapper(client.stream_completion)
client.acompletion = awrapper(client.acompletion)
client.astream_completion = stream_wrapper(client.astream_completion)
except Exception:
logger.warning(traceback.format_exc())
return client
@@ -0,0 +1,183 @@
import copy
import json
import aworld.trace.instrumentation.semconv as semconv
from aworld.models.model_response import ModelResponse, ToolCall
from aworld.trace.base import Span
from aworld.trace.instrumentation.openai.inout_parse import should_trace_prompts, need_flatten_messages
from aworld.logs.util import logger
from aworld.utils.serialized_util import to_serializable
def parser_request_params(kwargs, instance: 'aworld.models.llm.LLMModel'):
attributes = {
semconv.GEN_AI_SYSTEM: instance.provider_name,
semconv.GEN_AI_REQUEST_MODEL: instance.provider.model_name,
semconv.GEN_AI_REQUEST_MAX_TOKENS: kwargs.get("max_tokens", ""),
semconv.GEN_AI_REQUEST_TEMPERATURE: kwargs.get("temperature", ""),
semconv.GEN_AI_REQUEST_STOP_SEQUENCES: str(kwargs.get("stop", [])),
semconv.GEN_AI_REQUEST_FREQUENCY_PENALTY: kwargs.get("frequency_penalty", ""),
semconv.GEN_AI_REQUEST_PRESENCE_PENALTY: kwargs.get("presence_penalty", ""),
semconv.GEN_AI_REQUEST_USER: kwargs.get("user", ""),
semconv.GEN_AI_REQUEST_EXTRA_HEADERS: kwargs.get("extra_headers", ""),
semconv.GEN_AI_REQUEST_STREAMING: kwargs.get("stream", ""),
semconv.GEN_AI_REQUEST_TOP_P: kwargs.get("top_p", ""),
semconv.GEN_AI_OPERATION_NAME: "chat"
}
return attributes
async def handle_request(span: Span, kwargs, instance):
if not span or not span.is_recording():
return
try:
attributes = parser_request_params(kwargs, instance)
if should_trace_prompts():
messages = kwargs.get("messages")
if need_flatten_messages():
attributes.update(parse_request_message(messages))
else:
attributes.update({
semconv.GEN_AI_PROMPT: covert_to_jsonstr(messages)
})
tools = kwargs.get("tools")
if tools:
if need_flatten_messages():
attributes.update(parse_prompt_tools(tools))
else:
attributes.update({
semconv.GEN_AI_PROMPT_TOOLS: covert_to_jsonstr(tools)
})
filterd_attri = {k: v for k, v in attributes.items()
if (v and v != "")}
span.set_attributes(filterd_attri)
except Exception as e:
logger.warning(f"trace handle openai request error: {e}")
def get_common_attributes_from_response(instance: 'LLMModel', is_async, is_streaming):
operation = "acompletion" if is_async else "completion"
if is_streaming:
operation = "astream_completion" if is_async else "stream_completion"
return {
semconv.GEN_AI_SYSTEM: instance.provider_name,
semconv.GEN_AI_RESPONSE_MODEL: instance.provider.model_name,
semconv.GEN_AI_METHOD_NAME: operation,
semconv.GEN_AI_SERVER_ADDRESS: instance.provider.base_url
}
def accumulate_stream_response(chunk: ModelResponse, complete_response: dict):
from aworld.utils.common import nest_dict_counter
# logger.info(f"accumulate_stream_response chunk= {chunk}")
complete_response["model"] = chunk.model
complete_response["id"] = chunk.id
if chunk.content:
complete_response["content"] += chunk.content
if chunk.tool_calls:
complete_response["tool_calls"].extend(chunk.tool_calls)
if chunk.error:
complete_response["error"] = chunk.error
complete_response["usage"] = nest_dict_counter(
complete_response["usage"], chunk.usage)
def record_stream_token_usage(complete_response, request_kwargs) -> tuple[int, int]:
'''
return (prompt_usage, completion_usage)
'''
# logger.info(
# f"record_stream_token_usage complete_response= {complete_response}")
usage = complete_response.get("usage", {})
if usage:
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
return (prompt_tokens, completion_tokens)
return (0, 0)
def parse_request_message(messages):
'''
flatten request message to attributes
'''
attributes = {}
for i, msg in enumerate(messages):
prefix = f"{semconv.GEN_AI_PROMPT}.{i}"
attributes.update({f"{prefix}.role": msg.get("role")})
if msg.get("content"):
content = copy.deepcopy(msg.get("content"))
content = json.dumps(content, ensure_ascii=False)
attributes.update({f"{prefix}.content": content})
if msg.get("tool_call_id"):
attributes.update({
f"{prefix}.tool_call_id": msg.get("tool_call_id")})
tool_calls = msg.get("tool_calls")
# logger.info(f"input tool_calls={tool_calls}")
if tool_calls:
for i, tool_call in enumerate(tool_calls):
if isinstance(tool_call, dict):
function = tool_call.get('function')
attributes.update({
f"{prefix}.tool_calls.{i}.id": tool_call.get("id")})
attributes.update({
f"{prefix}.tool_calls.{i}.name": function.get("name")})
attributes.update({
f"{prefix}.tool_calls.{i}.arguments": function.get("arguments")})
elif isinstance(tool_call, ToolCall):
function = tool_call.function
attributes.update({
f"{prefix}.tool_calls.{i}.id": tool_call.id})
attributes.update({
f"{prefix}.tool_calls.{i}.name": function.name})
attributes.update({
f"{prefix}.tool_calls.{i}.arguments": function.arguments})
return attributes
def parse_prompt_tools(tools):
attributes = {}
for i, tool in enumerate(tools):
prefix = f"{semconv.GEN_AI_PROMPT_TOOLS}.{i}"
if isinstance(tool, dict):
tool_type = tool.get("type")
attributes.update({
f"{prefix}.type": tool_type})
if tool.get(tool_type):
attributes.update({
f"{prefix}.name": tool.get(tool_type).get("name")})
return attributes
def parse_response_message(tool_calls) -> dict:
attributes = {}
prefix = semconv.GEN_AI_COMPLETION_TOOL_CALLS
if tool_calls:
if need_flatten_messages():
for i, tool_call in enumerate(tool_calls):
function = tool_call.get("function")
attributes.update(
{f"{prefix}.{i}.id": tool_call.get("id")})
attributes.update(
{f"{prefix}.{i}.name": function.get("name")})
attributes.update(
{f"{prefix}.{i}.arguments": function.get("arguments")})
else:
attributes.update({
prefix: covert_to_jsonstr(tool_calls)
})
return attributes
def response_to_dic(response: ModelResponse) -> dict:
logger.info(f"completion response= {response}")
return response.to_dict()
def covert_to_jsonstr(obj):
try:
return json.dumps(to_serializable(obj), ensure_ascii=False)
except:
logger.warning(f"covert_to_jsonstr error: {obj.__class__.__name__}")
return str(obj)
@@ -0,0 +1,31 @@
from importlib import import_module
from wrapt import ObjectProxy
def unwrap(obj: object, attr: str):
"""Given a function that was wrapped by wrapt.wrap_function_wrapper, unwrap it
The object containing the function to unwrap may be passed as dotted module path string.
Args:
obj: Object that holds a reference to the wrapped function or dotted import path as string
attr (str): Name of the wrapped function
"""
if isinstance(obj, str):
try:
module_path, class_name = obj.rsplit(".", 1)
except ValueError as exc:
raise ImportError(
f"Cannot parse '{obj}' as dotted import path"
) from exc
module = import_module(module_path)
try:
obj = getattr(module, class_name)
except AttributeError as exc:
raise ImportError(
f"Cannot import '{class_name}' from '{module}'"
) from exc
func = getattr(obj, attr, None)
if func and isinstance(func, ObjectProxy) and hasattr(func, "__wrapped__"):
setattr(obj, attr, func.__wrapped__)