diff --git a/instrumentation-genai/opentelemetry-genai-sdk/.gitignore b/instrumentation-genai/opentelemetry-genai-sdk/.gitignore new file mode 100644 index 0000000000..ce987d45ce --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/.gitignore @@ -0,0 +1,170 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Mac files +.DS_Store + +# Environment variables +.env + +# sqlite database files +*.db +*.db-shm +*.db-wal + +# PNG files +*.png + +demo/ + +.ruff_cache + +.vscode/ + +output/ + +.terraform.lock.hcl +.terraform/ +foo.sh +tfplan +tfplan.txt +tfplan.json +terraform_output.json + + +# IntelliJ / PyCharm +.idea + + +*.txt + +.dockerconfigjson + +app/src/bedrock_agent/deploy diff --git a/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml b/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml index 5f657157ca..a995ea1cb0 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml @@ -25,9 +25,11 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-api ~= 1.36.0", - "opentelemetry-instrumentation ~= 0.57b0", - "opentelemetry-semantic-conventions ~= 0.57b0", + "opentelemetry-instrumentation ~= 0.51b0", + "opentelemetry-semantic-conventions ~= 0.51b0", + "opentelemetry-api>=1.31.0", + "opentelemetry-sdk>=1.31.0", + "pydantic-core>=2.33.2", ] [project.optional-dependencies] diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/api.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/api.py index 08d6b8c881..5afa859312 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/api.py +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/api.py @@ -21,7 +21,7 @@ from .exporters import SpanMetricEventExporter, SpanMetricExporter from .data import Message, ChatGeneration, Error, ToolOutput, ToolFunction -from opentelemetry.instrumentation.langchain.version import __version__ +from .version import __version__ from opentelemetry.metrics import get_meter from opentelemetry.trace import get_tracer from opentelemetry._events import get_event_logger diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/data.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/data.py index 00634bdab4..a126ab695a 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/data.py +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/data.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass, field -from typing import List +from dataclasses import dataclass, field +from typing import Optional, List @dataclass class ToolOutput: @@ -32,7 +32,7 @@ class Message: class ChatGeneration: content: str type: str - finish_reason: str = None + finish_reason: Optional[str] = None tool_function_calls: List[ToolFunctionCall] = field(default_factory=list) @dataclass diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/__init__.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/__init__.py new file mode 100644 index 0000000000..22adddd140 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/__init__.py @@ -0,0 +1,109 @@ +import inspect +from typing import Optional, Union, TypeVar, Callable, Awaitable + +from typing_extensions import ParamSpec + +from opentelemetry.genai.sdk.decorators.base import ( + entity_class, + entity_method, +) +from opentelemetry.genai.sdk.utils.const import ( + ObserveSpanKindValues, +) + +P = ParamSpec("P") +R = TypeVar("R") +F = TypeVar("F", bound=Callable[P, Union[R, Awaitable[R]]]) + + +def task( + name: Optional[str] = None, + method_name: Optional[str] = None, + tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, +) -> Callable[[F], F]: + def decorator(target): + # Check if target is a class + if inspect.isclass(target): + return entity_class( + name=name, + method_name=method_name, + tlp_span_kind=tlp_span_kind, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + tlp_span_kind=tlp_span_kind, + )(target) + return decorator + + +def workflow( + name: Optional[str] = None, + method_name: Optional[str] = None, + tlp_span_kind: Optional[ + Union[ObserveSpanKindValues, str] + ] = ObserveSpanKindValues.WORKFLOW, +) -> Callable[[F], F]: + def decorator(target): + # Check if target is a class + if inspect.isclass(target): + return entity_class( + name=name, + method_name=method_name, + tlp_span_kind=tlp_span_kind, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + tlp_span_kind=tlp_span_kind, + )(target) + + return decorator + + +def agent( + name: Optional[str] = None, + method_name: Optional[str] = None, +) -> Callable[[F], F]: + return workflow( + name=name, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.AGENT, + ) + + +def tool( + name: Optional[str] = None, + method_name: Optional[str] = None, +) -> Callable[[F], F]: + return task( + name=name, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.TOOL, + ) + + +def llm( + name: Optional[str] = None, + model_name: Optional[str] = None, + method_name: Optional[str] = None, +) -> Callable[[F], F]: + def decorator(target): + # Check if target is a class + if inspect.isclass(target): + return entity_class( + name=name, + model_name=model_name, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.LLM, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + model_name=model_name, + tlp_span_kind=ObserveSpanKindValues.LLM, + )(target) + return decorator diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/base.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/base.py new file mode 100644 index 0000000000..f79e4eb971 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/base.py @@ -0,0 +1,489 @@ +import json +from functools import wraps +import os +from typing import Optional, TypeVar, Callable, Awaitable, Any, Union +import inspect +import traceback + +from opentelemetry.genai.sdk.decorators.helpers import ( + _is_async_method, + _get_original_function_name, + _is_async_generator, +) + +from opentelemetry.genai.sdk.decorators.util import camel_to_snake +from opentelemetry import trace +from opentelemetry import context as context_api +from typing_extensions import ParamSpec +from ..version import __version__ + +from opentelemetry.genai.sdk.utils.const import ( + ObserveSpanKindValues, +) + +from opentelemetry.genai.sdk.data import Message, ChatGeneration +from opentelemetry.genai.sdk.exporters import _get_property_value + +from opentelemetry.genai.sdk.api import get_telemetry_client + +P = ParamSpec("P") + +R = TypeVar("R") +F = TypeVar("F", bound=Callable[P, Union[R, Awaitable[R]]]) + +OTEL_INSTRUMENTATION_GENAI_EXPORTER = ( + "OTEL_INSTRUMENTATION_GENAI_EXPORTER" +) + + +def should_emit_events() -> bool: + val = os.getenv(OTEL_INSTRUMENTATION_GENAI_EXPORTER, "SpanMetricEventExporter") + if val.strip().lower() == "spanmetriceventexporter": + return True + elif val.strip().lower() == "spanmetricexporter": + return False + else: + raise ValueError(f"Unknown exporter_type: {val}") + +exporter_type_full = should_emit_events() + +# Instantiate a singleton TelemetryClient bound to our tracer & meter +telemetry = get_telemetry_client(exporter_type_full) + + +def _get_parent_run_id(): + # Placeholder for parent run ID logic; return None if not available + return None + +def _should_send_prompts(): + return ( + os.getenv("OBSERVE_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res=None): + """Add GenAI-specific attributes to span for LLM operations by delegating to TelemetryClient logic.""" + if tlp_span_kind != ObserveSpanKindValues.LLM: + return None + + # Import here to avoid circular import issues + from uuid import uuid4 + + # Extract messages and attributes as before + messages = _extract_messages_from_args_kwargs(args, kwargs) + tool_functions = _extract_tool_functions_from_args_kwargs(args, kwargs) + run_id = uuid4() + + try: + telemetry.start_llm(prompts=messages, + tool_functions=tool_functions, + run_id=run_id, + parent_run_id=_get_parent_run_id(), + **_extract_llm_attributes_from_args_kwargs(args, kwargs, res)) + return run_id # Return run_id so it can be used later + except Exception as e: + print(f"Warning: TelemetryClient.start_llm failed: {e}") + return None + + +def _finish_llm_span(run_id, res, **attributes): + """Finish the LLM span with response data""" + if not run_id: + return + if res: + _extract_response_attributes(res, attributes) + chat_generations = _extract_chat_generations_from_response(res) + try: + import contextlib + with contextlib.suppress(Exception): + telemetry.stop_llm(run_id, chat_generations, **attributes) + except Exception as e: + print(f"Warning: TelemetryClient.stop_llm failed: {e}") + + +def _extract_messages_from_args_kwargs(args, kwargs): + """Extract messages from function arguments using patterns similar to exporters""" + messages = [] + + # Try different patterns to find messages + raw_messages = None + if kwargs.get('messages'): + raw_messages = kwargs['messages'] + elif kwargs.get('inputs'): # Sometimes messages are in inputs + inputs = kwargs['inputs'] + if isinstance(inputs, dict) and 'messages' in inputs: + raw_messages = inputs['messages'] + elif len(args) > 0: + # Try to find messages in args + for arg in args: + if hasattr(arg, 'messages'): + raw_messages = arg.messages + break + elif isinstance(arg, list) and arg and hasattr(arg[0], 'content'): + raw_messages = arg + break + + # Convert to Message objects using similar logic as exporters + if raw_messages: + for msg in raw_messages: + content = _get_property_value(msg, "content") + msg_type = _get_property_value(msg, "type") or _get_property_value(msg, "role") + # Convert 'human' to 'user' like in exporters + msg_type = "user" if msg_type == "human" else msg_type + + if content and msg_type: + # Provide default values for required arguments + messages.append(Message( + content=str(content), + name="", # Default empty name + type=str(msg_type), + tool_call_id="" # Default empty tool_call_id + )) + + return messages + + +def _extract_tool_functions_from_args_kwargs(args, kwargs): + """Extract tool functions from function arguments""" + from opentelemetry.genai.sdk.data import ToolFunction + + tool_functions = [] + + # Try to find tools in various places + tools = None + + # Check kwargs for tools + if kwargs.get('tools'): + tools = kwargs['tools'] + elif kwargs.get('functions'): + tools = kwargs['functions'] + + # Check args for objects that might have tools + if not tools and len(args) > 0: + for arg in args: + if hasattr(arg, 'tools'): + tools = getattr(arg, 'tools', []) + break + elif hasattr(arg, 'functions'): + tools = getattr(arg, 'functions', []) + break + + # Convert tools to ToolFunction objects + if tools: + for tool in tools: + try: + # Handle different tool formats + if hasattr(tool, 'name'): + # LangChain-style tool + tool_name = tool.name + tool_description = getattr(tool, 'description', '') + elif isinstance(tool, dict) and 'name' in tool: + # Dict-style tool + tool_name = tool['name'] + tool_description = tool.get('description', '') + elif hasattr(tool, '__name__'): + # Function-style tool + tool_name = tool.__name__ + tool_description = getattr(tool, '__doc__', '') or '' + else: + continue + + tool_functions.append(ToolFunction( + name=tool_name, + description=tool_description, + parameters={} + )) + except Exception: + # Skip tools that can't be processed + continue + + return tool_functions + +def _extract_llm_attributes_from_args_kwargs(args, kwargs, res=None): + """Extract LLM attributes from function arguments""" + attributes = {} + + # Extract model information + model = None + if kwargs.get('model'): + model = kwargs['model'] + elif kwargs.get('model_name'): + model = kwargs['model_name'] + elif len(args) > 0 and hasattr(args[0], 'model'): + model = getattr(args[0], 'model', None) + elif len(args) > 0 and isinstance(args[0], str): + model = args[0] # Sometimes model is the first string argument + + if model: + attributes['request_model'] = str(model) + + # Extract system/framework information + system = None + framework = None + + if kwargs.get('system'): + system = kwargs['system'] + elif hasattr(args[0] if args else None, '__class__'): + # Try to infer system from class name + class_name = args[0].__class__.__name__.lower() + if 'openai' in class_name or 'gpt' in class_name: + system = 'openai' + elif 'anthropic' in class_name or 'claude' in class_name: + system = 'anthropic' + elif 'google' in class_name or 'gemini' in class_name: + system = 'google' + elif 'langchain' in class_name: + system = 'langchain' + framework = 'langchain' + + if system is not None: + attributes['system'] = system + + if 'framework' in kwargs and kwargs['framework'] is not None: + framework = kwargs['framework'] + else: + framework = "unknown" + + if framework: + attributes['framework'] = framework + + # Extract response attributes if available + if res: + _extract_response_attributes(res, attributes) + + return attributes + + +def _extract_response_attributes(res, attributes): + """Extract attributes from response similar to exporter logic""" + try: + # Check if res has response_metadata attribute directly + metadata = None + if hasattr(res, 'response_metadata'): + metadata = res.response_metadata + elif isinstance(res, str): + # If res is a string, try to parse it as JSON + try: + parsed_res = json.loads(res) + metadata = parsed_res.get('response_metadata') + except: + pass + + # Extract token usage if available + if metadata and 'token_usage' in metadata: + usage = metadata['token_usage'] + if 'prompt_tokens' in usage: + attributes['input_tokens'] = usage['prompt_tokens'] + if 'completion_tokens' in usage: + attributes['output_tokens'] = usage['completion_tokens'] + + # Extract response model + if metadata and 'model_name' in metadata: + attributes['response_model_name'] = metadata['model_name'] + + # Extract response ID + if hasattr(res, 'id'): + attributes['response_id'] = res.id + except Exception: + # Silently ignore errors in extracting response attributes + pass + + +def _extract_chat_generations_from_response(res): + """Extract chat generations from response similar to exporter logic""" + chat_generations = [] + + try: + # Handle OpenAI-style responses with choices + if hasattr(res, 'choices') and res.choices: + for choice in res.choices: + content = None + finish_reason = None + msg_type = "assistant" + + if hasattr(choice, 'message') and hasattr(choice.message, 'content'): + content = choice.message.content + if hasattr(choice.message, 'role'): + msg_type = choice.message.role + + if hasattr(choice, 'finish_reason'): + finish_reason = choice.finish_reason + + if content: + chat_generations.append(ChatGeneration( + content=str(content), + finish_reason=finish_reason, + type=str(msg_type) + )) + + # Handle responses with direct content attribute (e.g., some LangChain responses) + elif hasattr(res, 'content'): + msg_type = "assistant" + if hasattr(res, 'type'): + msg_type = res.type + + chat_generations.append(ChatGeneration( + content=str(res.content), + finish_reason="stop", # May not be available + type=str(msg_type) + )) + + except Exception: + # Silently ignore errors in extracting chat generations + pass + + return chat_generations + + +def _unwrap_structured_tool(fn): + # Unwraps StructuredTool or similar wrappers to get the underlying function + if hasattr(fn, "func") and callable(fn.func): + return fn.func + return fn + + +def entity_method( + name: Optional[str] = None, + model_name: Optional[str] = None, + tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, +) -> Callable[[F], F]: + def decorate(fn: F) -> F: + fn = _unwrap_structured_tool(fn) + is_async = _is_async_method(fn) + entity_name = name or _get_original_function_name(fn) + if is_async: + if _is_async_generator(fn): + @wraps(fn) + async def async_gen_wrap(*args: Any, **kwargs: Any) -> Any: + + # add entity_name to kwargs + kwargs["system"] = entity_name + _handle_llm_span_attributes(tlp_span_kind, args, kwargs) + async for item in fn(*args, **kwargs): + yield item + + return async_gen_wrap + else: + @wraps(fn) + async def async_wrap(*args, **kwargs): + try: + # Start LLM span before the call + run_id = None + if tlp_span_kind == ObserveSpanKindValues.LLM: + run_id = _handle_llm_span_attributes(tlp_span_kind, args, kwargs) + + res = await fn(*args, **kwargs) + if tlp_span_kind == ObserveSpanKindValues.LLM and run_id: + kwargs["system"] = entity_name + # Extract attributes from args and kwargs + attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) + + _finish_llm_span(run_id, res, **attributes) + + except Exception as e: + print(traceback.format_exc()) + raise e + return res + + decorated = async_wrap + else: + @wraps(fn) + def sync_wrap(*args: Any, **kwargs: Any) -> Any: + try: + # Start LLM span before the call + run_id = None + if tlp_span_kind == ObserveSpanKindValues.LLM: + # Handle LLM span attributes + run_id = _handle_llm_span_attributes(tlp_span_kind, args, kwargs) + + res = fn(*args, **kwargs) + + # Finish LLM span after the call + if tlp_span_kind == ObserveSpanKindValues.LLM and run_id: + kwargs["system"] = entity_name + # Extract attributes from args and kwargs + attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) + + _finish_llm_span(run_id, res, **attributes) + + except Exception as e: + print(traceback.format_exc()) + raise e + return res + + decorated = sync_wrap + # # If the original fn was a StructuredTool, re-wrap + if hasattr(fn, "func") and callable(fn.func): + fn.func = decorated + return fn + return decorated + + return decorate + + +def entity_class( + name: Optional[str], + model_name: Optional[str], + method_name: Optional[str], + tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, +): + def decorator(cls): + task_name = name if name else camel_to_snake(cls.__qualname__) + + methods_to_wrap = [] + + if method_name: + # Specific method specified - existing behavior + methods_to_wrap = [method_name] + else: + # No method specified - wrap all public methods defined in this class + for attr_name in dir(cls): + if ( + not attr_name.startswith("_") # Skip private/built-in methods + and attr_name != "mro" # Skip class method + and hasattr(cls, attr_name) + ): + attr = getattr(cls, attr_name) + # Only wrap functions defined in this class (not inherited methods or built-ins) + if ( + inspect.isfunction(attr) # Functions defined in the class + and not isinstance(attr, (classmethod, staticmethod, property)) + and hasattr(attr, "__qualname__") # Has qualname attribute + and attr.__qualname__.startswith( + cls.__name__ + "." + ) # Defined in this class + ): + # Additional check: ensure the function has a proper signature with 'self' parameter + try: + sig = inspect.signature(attr) + params = list(sig.parameters.keys()) + if params and params[0] == "self": + methods_to_wrap.append(attr_name) + except (ValueError, TypeError): + # Skip methods that can't be inspected + continue + + # Wrap all detected methods + for method_to_wrap in methods_to_wrap: + if hasattr(cls, method_to_wrap): + original_method = getattr(cls, method_to_wrap) + # Only wrap actual functions defined in this class + unwrapped_method = _unwrap_structured_tool(original_method) + if inspect.isfunction(unwrapped_method): + try: + # Verify the method has a proper signature + sig = inspect.signature(unwrapped_method) + wrapped_method = entity_method( + name=f"{task_name}.{method_to_wrap}", + model_name=model_name, + tlp_span_kind=tlp_span_kind, + )(unwrapped_method) + # Set the wrapped method on the class + setattr(cls, method_to_wrap, wrapped_method) + except Exception: + # Don't wrap methods that can't be properly decorated + continue + + return cls + + return decorator diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/helpers.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/helpers.py new file mode 100644 index 0000000000..50e213b52f --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/helpers.py @@ -0,0 +1,341 @@ +import inspect + + +def _is_async_method(fn): + # check if co-routine function or async generator( example : using async & yield) + if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn): + return True + + # Check if this is a wrapped function that might hide the original async nature + # Look for common wrapper attributes that might contain the original function + for attr_name in ["__wrapped__", "func", "_func", "function"]: + if hasattr(fn, attr_name): + wrapped_fn = getattr(fn, attr_name) + if wrapped_fn and callable(wrapped_fn): + if inspect.iscoroutinefunction( + wrapped_fn + ) or inspect.isasyncgenfunction(wrapped_fn): + return True + # Recursively check in case of multiple levels of wrapping + if _is_async_method(wrapped_fn): + return True + + return False + + +def _is_async_generator(fn): + """Check if function is an async generator, looking through wrapped functions""" + if inspect.isasyncgenfunction(fn): + return True + + # Check if this is a wrapped function that might hide the original async generator nature + for attr_name in ["__wrapped__", "func", "_func", "function"]: + if hasattr(fn, attr_name): + wrapped_fn = getattr(fn, attr_name) + if wrapped_fn and callable(wrapped_fn): + if inspect.isasyncgenfunction(wrapped_fn): + return True + # Recursively check in case of multiple levels of wrapping + if _is_async_generator(wrapped_fn): + return True + + return False + + +def _get_original_function_name(fn): + """Extract the original function name from potentially wrapped functions""" + if hasattr(fn, "__qualname__") and fn.__qualname__: + return fn.__qualname__ + + # Look for the original function in common wrapper attributes + for attr_name in ["__wrapped__", "func", "_func", "function"]: + if hasattr(fn, attr_name): + wrapped_fn = getattr(fn, attr_name) + if wrapped_fn and callable(wrapped_fn): + if hasattr(wrapped_fn, "__qualname__") and wrapped_fn.__qualname__: + return wrapped_fn.__qualname__ + # Recursively check in case of multiple levels of wrapping + result = _get_original_function_name(wrapped_fn) + if result: + return result + + # Fallback to function name if qualname is not available + return getattr(fn, "__name__", "unknown_function") + + +def _extract_tool_functions_from_args_kwargs(args, kwargs): + """Extract tool functions from function arguments""" + from opentelemetry.genai.sdk.data import ToolFunction + + tool_functions = [] + + # Try to find tools in various places + tools = None + + # Check kwargs for tools + if kwargs.get('tools'): + tools = kwargs['tools'] + elif kwargs.get('functions'): + tools = kwargs['functions'] + + # Check args for objects that might have tools + if not tools and len(args) > 0: + for arg in args: + if hasattr(arg, 'tools'): + tools = getattr(arg, 'tools', []) + break + elif hasattr(arg, 'functions'): + tools = getattr(arg, 'functions', []) + break + + # Convert tools to ToolFunction objects + if tools: + for tool in tools: + try: + # Handle different tool formats + if hasattr(tool, 'name'): + # LangChain-style tool + tool_name = tool.name + tool_description = getattr(tool, 'description', '') + elif isinstance(tool, dict) and 'name' in tool: + # Dict-style tool + tool_name = tool['name'] + tool_description = tool.get('description', '') + elif hasattr(tool, '__name__'): + # Function-style tool + tool_name = tool.__name__ + tool_description = getattr(tool, '__doc__', '') or '' + else: + continue + + tool_functions.append(ToolFunction( + name=tool_name, + description=tool_description, + parameters={} # Add parameter extraction if needed + )) + except Exception: + # Skip tools that can't be processed + continue + + return tool_functions + + +def _find_llm_instance(args, kwargs): + """Find LLM instance using multiple approaches""" + llm_instance = None + + try: + import sys + frame = sys._getframe(2) # Get the decorated function's frame + func = frame.f_code + + # Try to get the function object from the frame + if hasattr(frame, 'f_globals'): + for name, obj in frame.f_globals.items(): + if (hasattr(obj, '__code__') and + obj.__code__ == func and + hasattr(obj, 'llm')): + llm_instance = obj.llm + break + except: + pass + + # Check kwargs for LLM instance + if not llm_instance: + for key, value in kwargs.items(): + if key.lower() in ['llm', 'model', 'client'] and _is_llm_instance(value): + llm_instance = value + break + + # Check args for LLM instance + if not llm_instance: + for arg in args: + if _is_llm_instance(arg): + llm_instance = arg + break + # Check for bound tools that contain an LLM + elif hasattr(arg, 'llm') and _is_llm_instance(arg.llm): + llm_instance = arg.llm + break + + # Frame inspection to look in local variables + if not llm_instance: + try: + import sys + frame = sys._getframe(2) # Go up 2 frames to get to the decorated function + local_vars = frame.f_locals + + # Look for ChatOpenAI or similar instances in local variables + for var_name, var_value in local_vars.items(): + if _is_llm_instance(var_value): + llm_instance = var_value + break + elif hasattr(var_value, 'llm') and _is_llm_instance(var_value.llm): + # Handle bound tools case + llm_instance = var_value.llm + break + except: + pass + + return llm_instance + + +def _is_llm_instance(obj): + """Check if an object is an LLM instance""" + if not hasattr(obj, '__class__'): + return False + + class_name = obj.__class__.__name__ + module_name = obj.__class__.__module__ if hasattr(obj.__class__, '__module__') else '' + + # Check for common LLM class patterns + llm_patterns = [ + 'ChatOpenAI', 'OpenAI', 'AzureOpenAI', 'AzureChatOpenAI', + 'ChatAnthropic', 'Anthropic', + 'ChatGoogleGenerativeAI', 'GoogleGenerativeAI', + 'ChatVertexAI', 'VertexAI', + 'ChatOllama', 'Ollama', + 'ChatHuggingFace', 'HuggingFace', + 'ChatCohere', 'Cohere' + ] + + return any(pattern in class_name for pattern in llm_patterns) or 'langchain' in module_name.lower() + + +def _extract_llm_config_attributes(llm_instance, attributes): + """Extract configuration attributes from LLM instance""" + try: + # Extract model + if hasattr(llm_instance, 'model_name') and llm_instance.model_name: + attributes['request_model'] = str(llm_instance.model_name) + elif hasattr(llm_instance, 'model') and llm_instance.model: + attributes['request_model'] = str(llm_instance.model) + + # Extract temperature + if hasattr(llm_instance, 'temperature') and llm_instance.temperature is not None: + attributes['request_temperature'] = float(llm_instance.temperature) + + # Extract max_tokens + if hasattr(llm_instance, 'max_tokens') and llm_instance.max_tokens is not None: + attributes['request_max_tokens'] = int(llm_instance.max_tokens) + + # Extract top_p + if hasattr(llm_instance, 'top_p') and llm_instance.top_p is not None: + attributes['request_top_p'] = float(llm_instance.top_p) + + # Extract top_k + if hasattr(llm_instance, 'top_k') and llm_instance.top_k is not None: + attributes['request_top_k'] = int(llm_instance.top_k) + + # Extract frequency_penalty + if hasattr(llm_instance, 'frequency_penalty') and llm_instance.frequency_penalty is not None: + attributes['request_frequency_penalty'] = float(llm_instance.frequency_penalty) + + # Extract presence_penalty + if hasattr(llm_instance, 'presence_penalty') and llm_instance.presence_penalty is not None: + attributes['request_presence_penalty'] = float(llm_instance.presence_penalty) + + # Extract seed + if hasattr(llm_instance, 'seed') and llm_instance.seed is not None: + attributes['request_seed'] = int(llm_instance.seed) + + # Extract stop sequences + if hasattr(llm_instance, 'stop') and llm_instance.stop is not None: + stop = llm_instance.stop + if isinstance(stop, (list, tuple)): + attributes['request_stop_sequences'] = list(stop) + else: + attributes['request_stop_sequences'] = [str(stop)] + elif hasattr(llm_instance, 'stop_sequences') and llm_instance.stop_sequences is not None: + stop = llm_instance.stop_sequences + if isinstance(stop, (list, tuple)): + attributes['request_stop_sequences'] = list(stop) + else: + attributes['request_stop_sequences'] = [str(stop)] + + except Exception as e: + print(f"Error extracting LLM config attributes: {e}") + + +def _extract_direct_parameters(args, kwargs, attributes): + """Fallback method to extract parameters directly from args/kwargs""" + # Temperature + print("args:", args) + print("kwargs:", kwargs) + temperature = kwargs.get('temperature') + if temperature is not None: + attributes['request_temperature'] = float(temperature) + elif hasattr(args[0] if args else None, 'temperature'): + temperature = getattr(args[0], 'temperature', None) + if temperature is not None: + attributes['request_temperature'] = float(temperature) + + # Max tokens + max_tokens = kwargs.get('max_tokens') or kwargs.get('max_completion_tokens') + if max_tokens is not None: + attributes['request_max_tokens'] = int(max_tokens) + elif hasattr(args[0] if args else None, 'max_tokens'): + max_tokens = getattr(args[0], 'max_tokens', None) + if max_tokens is not None: + attributes['request_max_tokens'] = int(max_tokens) + + # Top P + top_p = kwargs.get('top_p') + if top_p is not None: + attributes['request_top_p'] = float(top_p) + elif hasattr(args[0] if args else None, 'top_p'): + top_p = getattr(args[0], 'top_p', None) + if top_p is not None: + attributes['request_top_p'] = float(top_p) + + # Top K + top_k = kwargs.get('top_k') + if top_k is not None: + attributes['request_top_k'] = int(top_k) + elif hasattr(args[0] if args else None, 'top_k'): + top_k = getattr(args[0], 'top_k', None) + if top_k is not None: + attributes['request_top_k'] = int(top_k) + + # Frequency penalty + frequency_penalty = kwargs.get('frequency_penalty') + if frequency_penalty is not None: + attributes['request_frequency_penalty'] = float(frequency_penalty) + elif hasattr(args[0] if args else None, 'frequency_penalty'): + frequency_penalty = getattr(args[0], 'frequency_penalty', None) + if frequency_penalty is not None: + attributes['request_frequency_penalty'] = float(frequency_penalty) + + # Presence penalty + presence_penalty = kwargs.get('presence_penalty') + if presence_penalty is not None: + attributes['request_presence_penalty'] = float(presence_penalty) + elif hasattr(args[0] if args else None, 'presence_penalty'): + presence_penalty = getattr(args[0], 'presence_penalty', None) + if presence_penalty is not None: + attributes['request_presence_penalty'] = float(presence_penalty) + + # Stop sequences + stop_sequences = kwargs.get('stop_sequences') or kwargs.get('stop') + if stop_sequences is not None: + if isinstance(stop_sequences, (list, tuple)): + attributes['request_stop_sequences'] = list(stop_sequences) + else: + attributes['request_stop_sequences'] = [str(stop_sequences)] + elif hasattr(args[0] if args else None, 'stop_sequences'): + stop_sequences = getattr(args[0], 'stop_sequences', None) + if stop_sequences is not None: + if isinstance(stop_sequences, (list, tuple)): + attributes['request_stop_sequences'] = list(stop_sequences) + else: + attributes['request_stop_sequences'] = [str(stop_sequences)] + + # Seed + seed = kwargs.get('seed') + if seed is not None: + attributes['request_seed'] = int(seed) + elif hasattr(args[0] if args else None, 'seed'): + seed = getattr(args[0], 'seed', None) + if seed is not None: + attributes['request_seed'] = int(seed) + \ No newline at end of file diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/util.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/util.py new file mode 100644 index 0000000000..a2949afcdf --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/util.py @@ -0,0 +1,138 @@ +def _serialize_object(obj, max_depth=3, current_depth=0): + """ + Intelligently serialize an object to a more meaningful representation + """ + if current_depth > max_depth: + return f"<{type(obj).__name__}:max_depth_reached>" + + # Handle basic JSON-serializable types + if obj is None or isinstance(obj, (bool, int, float, str)): + return obj + + # Handle lists and tuples + if isinstance(obj, (list, tuple)): + try: + return [ + _serialize_object(item, max_depth, current_depth + 1) + for item in obj[:10] + ] # Limit to first 10 items + except Exception: + return f"<{type(obj).__name__}:length={len(obj)}>" + + # Handle dictionaries + if isinstance(obj, dict): + try: + serialized = {} + for key, value in list(obj.items())[:10]: # Limit to first 10 items + serialized[str(key)] = _serialize_object( + value, max_depth, current_depth + 1 + ) + return serialized + except Exception: + return f"" + + # Handle common object types with meaningful attributes + try: + # Check class attributes first + class_attrs = {} + for attr_name in dir(type(obj)): + if ( + not attr_name.startswith("_") + and not callable(getattr(type(obj), attr_name, None)) + and hasattr(obj, attr_name) + ): + try: + attr_value = getattr(obj, attr_name) + if not callable(attr_value): + class_attrs[attr_name] = _serialize_object( + attr_value, max_depth, current_depth + 1 + ) + if len(class_attrs) >= 5: # Limit attributes + break + except Exception: + continue + + # Check if object has a __dict__ with interesting attributes + instance_attrs = {} + if hasattr(obj, "__dict__"): + obj_dict = obj.__dict__ + if obj_dict: + # Extract meaningful attributes (skip private ones and callables) + for key, value in obj_dict.items(): + if not key.startswith("_") and not callable(value): + try: + instance_attrs[key] = _serialize_object( + value, max_depth, current_depth + 1 + ) + if len(instance_attrs) >= 5: # Limit attributes + break + except Exception: + continue + + # Combine class and instance attributes + all_attrs = {**class_attrs, **instance_attrs} + + if all_attrs: + return { + "__class__": type(obj).__name__, + "__module__": getattr(type(obj), "__module__", "unknown"), + "attributes": all_attrs, + } + + # Special handling for specific types + if hasattr(obj, "message") and hasattr(obj.message, "parts"): + # Handle RequestContext-like objects + try: + parts_content = [] + for part in obj.message.parts: + if hasattr(part, "root") and hasattr(part.root, "text"): + parts_content.append(part.root.text) + return { + "__class__": type(obj).__name__, + "message_content": parts_content, + } + except Exception: + pass + + # Check for common readable attributes + for attr in ["name", "id", "type", "value", "content", "text", "data"]: + if hasattr(obj, attr): + try: + attr_value = getattr(obj, attr) + if not callable(attr_value): + return { + "__class__": type(obj).__name__, + attr: _serialize_object( + attr_value, max_depth, current_depth + 1 + ), + } + except Exception: + continue + + # Fallback to class information + return { + "__class__": type(obj).__name__, + "__module__": getattr(type(obj), "__module__", "unknown"), + "__repr__": str(obj)[:100] + ("..." if len(str(obj)) > 100 else ""), + } + + except Exception: + # Final fallback + return f"<{type(obj).__name__}:serialization_failed>" + + +def cameltosnake(camel_string: str) -> str: + if not camel_string: + return "" + elif camel_string[0].isupper(): + return f"_{camel_string[0].lower()}{cameltosnake(camel_string[1:])}" + else: + return f"{camel_string[0]}{cameltosnake(camel_string[1:])}" + + +def camel_to_snake(s): + if len(s) <= 1: + return s.lower() + + return cameltosnake(s[0].lower() + s[1:]) + diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/const.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/const.py new file mode 100644 index 0000000000..931a24a093 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/const.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class ObserveSpanKindValues(Enum): + WORKFLOW = "workflow" + TASK = "task" + AGENT = "agent" + TOOL = "tool" + LLM = "llm" + UNKNOWN = "unknown" + diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/json_encoder.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/json_encoder.py new file mode 100644 index 0000000000..ad35a3b504 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/json_encoder.py @@ -0,0 +1,23 @@ +import dataclasses +import json + + +class JSONEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, dict): + if "callbacks" in o: + del o["callbacks"] + return o + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) + + if hasattr(o, "to_json"): + return o.to_json() + + if hasattr(o, "json"): + return o.json() + + if hasattr(o, "__class__"): + return o.__class__.__name__ + + return super().default(o) diff --git a/instrumentation-genai/opentelemetry-instrumentation-langchain/.gitignore b/instrumentation-genai/opentelemetry-instrumentation-langchain/.gitignore new file mode 100644 index 0000000000..15f55bffd6 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/.gitignore @@ -0,0 +1,168 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Mac files +.DS_Store + +# Environment variables +.env + +# sqlite database files +*.db +*.db-shm +*.db-wal + +# PNG files +*.png + +demo/ + +.ruff_cache + +.vscode/ + +output/ + +.terraform.lock.hcl +.terraform/ +foo.sh +tfplan +tfplan.txt +tfplan.json +terraform_output.json + + +# IntelliJ / PyCharm +.idea + + +*.txt + +.dockerconfigjson diff --git a/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py b/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py new file mode 100644 index 0000000000..12143c6cf2 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py @@ -0,0 +1,66 @@ +import os +from dotenv import load_dotenv +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI + +from opentelemetry.genai.sdk.decorators import llm +from opentelemetry import _events, _logs, trace, metrics + +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter + +from opentelemetry.sdk._events import EventLoggerProvider +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + +# configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter()) +metrics.set_meter_provider(MeterProvider(metric_readers=[metric_reader])) + +# configure logging and events +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) +_events.set_event_logger_provider(EventLoggerProvider()) + +# Load environment variables from .env file +load_dotenv() + +@llm(name="invoke_langchain_model") +def invoke_model(messages): + # Get API key from environment variable or set a placeholder + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable must be set") + + llm = ChatOpenAI(model="gpt-3.5-turbo", api_key=api_key) + result = llm.invoke(messages) + return result + +def main(): + + messages = [ + SystemMessage(content="You are a helpful assistant!"), + HumanMessage(content="What is the capital of France?"), + ] + + result = invoke_model(messages) + print("LLM output:\n", result) + +if __name__ == "__main__": + main()