From 2f04b962401594c3fd6adbc109b00b57f8c7aaff Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Sun, 3 Aug 2025 11:33:42 +0100 Subject: [PATCH 1/7] feat: Add decorator support in GenAI Utils SDK Signed-off-by: Pavan Sudheendra --- .../opentelemetry-genai-sdk/.gitignore | 170 +++++++ .../opentelemetry-genai-sdk/pyproject.toml | 2 +- .../src/opentelemetry/genai/sdk/api.py | 2 +- .../src/opentelemetry/genai/sdk/data.py | 4 +- .../genai/sdk/decorators/__init__.py | 140 ++++++ .../genai/sdk/decorators/base.py | 451 ++++++++++++++++++ .../genai/sdk/decorators/helpers.py | 63 +++ .../genai/sdk/decorators/util.py | 138 ++++++ .../src/opentelemetry/genai/sdk/exporters.py | 10 + .../opentelemetry/genai/sdk/utils/const.py | 11 + .../genai/sdk/utils/json_encoder.py | 23 + .../.gitignore | 168 +++++++ .../examples/decorator/main.py | 33 ++ 13 files changed, 1211 insertions(+), 4 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/.gitignore create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/__init__.py create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/base.py create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/helpers.py create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/util.py create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/const.py create mode 100644 instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/utils/json_encoder.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-langchain/.gitignore create mode 100644 instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py 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 5f89010ab6..a995ea1cb0 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-genai-sdk/pyproject.toml @@ -25,11 +25,11 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-api ~= 1.30", "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 c8d7681362..c1d88ae2be 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 -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 65a9bd1a39..8a33f532d0 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,5 +1,5 @@ from dataclasses import dataclass - +from typing import Optional @dataclass class Message: @@ -10,7 +10,7 @@ class Message: class ChatGeneration: content: str type: str - finish_reason: str = None + finish_reason: Optional[str] = None @dataclass class Error: 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..618a57cf27 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/__init__.py @@ -0,0 +1,140 @@ +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, + description: Optional[str] = None, + version: Optional[int] = None, + protocol: 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, + description=description, + version=version, + protocol=protocol, + method_name=method_name, + tlp_span_kind=tlp_span_kind, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + description=description, + version=version, + protocol=protocol, + tlp_span_kind=tlp_span_kind, + )(target) + return decorator + + +def workflow( + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[int] = None, + protocol: 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, + description=description, + version=version, + protocol=protocol, + method_name=method_name, + tlp_span_kind=tlp_span_kind, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + description=description, + version=version, + protocol=protocol, + tlp_span_kind=tlp_span_kind, + )(target) + + return decorator + + +def agent( + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[int] = None, + protocol: Optional[str] = None, + method_name: Optional[str] = None, +) -> Callable[[F], F]: + return workflow( + name=name, + description=description, + version=version, + protocol=protocol, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.AGENT, + ) + + +def tool( + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[int] = None, + method_name: Optional[str] = None, +) -> Callable[[F], F]: + return task( + name=name, + description=description, + version=version, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.TOOL, + ) + + +def llm( + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[int] = 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, + description=description, + version=version, + method_name=method_name, + tlp_span_kind=ObserveSpanKindValues.LLM, + )(target) + else: + # Target is a function/method + return entity_method( + name=name, + description=description, + version=version, + 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..65faa5e563 --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/base.py @@ -0,0 +1,451 @@ +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 + +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry import trace, metrics +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 OpenTelemetry providers (add this after imports) +def _configure_telemetry(): + """Configure OpenTelemetry providers if not already configured""" + # Check if providers are already configured + try: + # Configure tracing + tracer_provider = TracerProvider() + trace.set_tracer_provider(tracer_provider) + + # Check environment variable to decide which exporter to use + span_exporter = OTLPSpanExporter() + + span_processor = BatchSpanProcessor(span_exporter) + tracer_provider.add_span_processor(span_processor) + + # Configure metrics + metric_exporter = OTLPMetricExporter() + metric_reader = PeriodicExportingMetricReader(metric_exporter) + meter_provider = MeterProvider(metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + + # 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()) + + print("OpenTelemetry providers configured successfully") + except Exception as e: + print(f"Warning: Failed to configure OpenTelemetry providers - {e}") + +_configure_telemetry() + + +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 _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 + + # Import here to avoid circular import issues + from uuid import uuid4 + import contextlib + + # Extract messages and attributes as before + messages = _extract_messages_from_args_kwargs(args, kwargs) + attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) + run_id = uuid4() + + # Pass the current span to TelemetryClient via context + # context_api.set_value("active_llm_span", span) + + try: + telemetry.start_llm(prompts=messages, run_id=run_id, **attributes) + except Exception as e: + print(f"Warning: TelemetryClient.start_llm failed: {e}") + return + + if res: + chat_generations = _extract_chat_generations_from_response(res) + try: + with contextlib.suppress(Exception): + telemetry.stop_llm(run_id=run_id, chat_generations=chat_generations) + 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: + messages.append(Message(content=str(content), type=str(msg_type))) + + return messages + + +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, + description: Optional[str] = None, + version: Optional[int] = None, + protocol: Optional[str] = None, + tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, +) -> Callable[[F], F]: + def decorate(fn: F) -> F: + # Unwrap StructuredTool if present + 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) + + return async_gen_wrap + else: + + @wraps(fn) + async def async_wrap(*args, **kwargs): + try: + res = await fn(*args, **kwargs) + + # Add GenAI-specific attributes from response for LLM spans + _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res) + 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: + res = fn(*args, **kwargs) + # Add entity_name to kwargs + kwargs["system"] = entity_name + # Add GenAI-specific attributes from response for LLM spans + _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res) + + 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], + description: Optional[str], + version: Optional[int], + protocol: 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}", + description=description, + version=version, + protocol=protocol, + 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..d97419622c --- /dev/null +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/decorators/helpers.py @@ -0,0 +1,63 @@ +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") 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/exporters.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py index 9c1ea5b4a4..71f88a28b6 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py @@ -225,6 +225,16 @@ def export(self, invocation: LLMInvocation): if completion_tokens is not None: span.set_attribute(GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, completion_tokens) + for index, message in enumerate(invocation.messages): + content = message.content + type = message.type + span.set_attribute(f"gen_ai.prompt.{index}.content", content) + span.set_attribute(f"gen_ai.prompt.{index}.role", type) + + for index, chat_generation in enumerate(invocation.chat_generations): + span.set_attribute(f"gen_ai.completion.{index}.content", chat_generation.content) + span.set_attribute(f"gen_ai.completion.{index}.role", chat_generation.type) + metric_attributes = _get_metric_attributes(request_model, response_model, GenAI.GenAiOperationNameValues.CHAT.value, system, framework) # Record token usage metrics 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..0ca74c50b5 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py @@ -0,0 +1,33 @@ +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 + +# 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() From da8e986b2d4597ebcbbc5210851206a23ed89b05 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Mon, 4 Aug 2025 10:07:16 +0100 Subject: [PATCH 2/7] refactor: remove print statement Signed-off-by: Pavan Sudheendra --- .../src/opentelemetry/genai/sdk/decorators/base.py | 2 -- 1 file changed, 2 deletions(-) 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 index 65faa5e563..2349b9b066 100644 --- 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 @@ -63,8 +63,6 @@ def _configure_telemetry(): # BatchLogRecordProcessor(OTLPLogExporter()) # ) # _events.set_event_logger_provider(EventLoggerProvider()) - - print("OpenTelemetry providers configured successfully") except Exception as e: print(f"Warning: Failed to configure OpenTelemetry providers - {e}") From 1c56a53bba519ebe852d55bf34dda6c348399607 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Tue, 5 Aug 2025 04:20:21 +0100 Subject: [PATCH 3/7] feat: reuse the telemetry client in api.py and only set the trace provider if not already set Signed-off-by: Pavan Sudheendra --- .../genai/sdk/decorators/base.py | 60 +++++-------------- 1 file changed, 16 insertions(+), 44 deletions(-) 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 index 2349b9b066..703b5081ce 100644 --- 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 @@ -26,47 +26,23 @@ from opentelemetry.genai.sdk.api import get_telemetry_client -from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( - OTLPSpanExporter, -) -from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter -from opentelemetry import trace, metrics -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 OpenTelemetry providers (add this after imports) -def _configure_telemetry(): - """Configure OpenTelemetry providers if not already configured""" - # Check if providers are already configured - try: - # Configure tracing - tracer_provider = TracerProvider() - trace.set_tracer_provider(tracer_provider) - - # Check environment variable to decide which exporter to use - span_exporter = OTLPSpanExporter() - - span_processor = BatchSpanProcessor(span_exporter) - tracer_provider.add_span_processor(span_processor) - - # Configure metrics - metric_exporter = OTLPMetricExporter() - metric_reader = PeriodicExportingMetricReader(metric_exporter) - meter_provider = MeterProvider(metric_readers=[metric_reader]) - metrics.set_meter_provider(meter_provider) - - # 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()) - except Exception as e: - print(f"Warning: Failed to configure OpenTelemetry providers - {e}") +from opentelemetry import trace + +def _ensure_tracer_provider(): + # Only set a default TracerProvider if one isn't set + if type(trace.get_tracer_provider()).__name__ == "ProxyTracerProvider": + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + exporter_protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() + if exporter_protocol == "http" or exporter_protocol == "http/protobuf": + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + else: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) -_configure_telemetry() +_ensure_tracer_provider() P = ParamSpec("P") @@ -320,13 +296,11 @@ def entity_method( tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, ) -> Callable[[F], F]: def decorate(fn: F) -> F: - # Unwrap StructuredTool if present 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: @@ -336,7 +310,6 @@ async def async_gen_wrap(*args: Any, **kwargs: Any) -> Any: return async_gen_wrap else: - @wraps(fn) async def async_wrap(*args, **kwargs): try: @@ -351,7 +324,6 @@ async def async_wrap(*args, **kwargs): decorated = async_wrap else: - @wraps(fn) def sync_wrap(*args: Any, **kwargs: Any) -> Any: try: From 498db98f982f7d6c702f036a849fb9be1162a610 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Thu, 7 Aug 2025 20:03:18 +0100 Subject: [PATCH 4/7] feat: call start_llm before the call is actually made Signed-off-by: Pavan Sudheendra --- .../genai/sdk/decorators/base.py | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 deletions(-) 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 index 703b5081ce..b59ccb289b 100644 --- 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 @@ -79,33 +79,37 @@ def _should_send_prompts(): 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 + return None # Import here to avoid circular import issues from uuid import uuid4 - import contextlib # Extract messages and attributes as before messages = _extract_messages_from_args_kwargs(args, kwargs) - attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) + # attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) run_id = uuid4() - # Pass the current span to TelemetryClient via context - # context_api.set_value("active_llm_span", span) - try: - telemetry.start_llm(prompts=messages, run_id=run_id, **attributes) + telemetry.start_llm(prompts=messages, run_id=run_id) + 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 + return None + +def _finish_llm_span(run_id, res, **attributes): + """Finish the LLM span with response data""" + if not run_id: + return if res: - chat_generations = _extract_chat_generations_from_response(res) - try: - with contextlib.suppress(Exception): - telemetry.stop_llm(run_id=run_id, chat_generations=chat_generations) - except Exception as e: - print(f"Warning: TelemetryClient.stop_llm failed: {e}") + _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): @@ -229,7 +233,6 @@ def _extract_response_attributes(res, attributes): # Extract response ID if hasattr(res, 'id'): attributes['response_id'] = res.id - except Exception: # Silently ignore errors in extracting response attributes pass @@ -307,16 +310,27 @@ 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) - # Add GenAI-specific attributes from response for LLM spans - _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res) + _finish_llm_span(run_id, res, **attributes) + except Exception as e: print(traceback.format_exc()) raise e @@ -327,12 +341,22 @@ async def async_wrap(*args, **kwargs): @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) - # Add entity_name to kwargs - kwargs["system"] = entity_name - # Add GenAI-specific attributes from response for LLM spans - _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res) + # 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 From 56a11da063dbcc049f643336f77061bb73beed72 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Fri, 8 Aug 2025 11:00:21 +0100 Subject: [PATCH 5/7] feat: remove publishing conversational content as span attributes in SpanMetricEvent exporter Signed-off-by: Pavan Sudheendra --- .../src/opentelemetry/genai/sdk/exporters.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py index 71f88a28b6..9c1ea5b4a4 100644 --- a/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py +++ b/instrumentation-genai/opentelemetry-genai-sdk/src/opentelemetry/genai/sdk/exporters.py @@ -225,16 +225,6 @@ def export(self, invocation: LLMInvocation): if completion_tokens is not None: span.set_attribute(GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, completion_tokens) - for index, message in enumerate(invocation.messages): - content = message.content - type = message.type - span.set_attribute(f"gen_ai.prompt.{index}.content", content) - span.set_attribute(f"gen_ai.prompt.{index}.role", type) - - for index, chat_generation in enumerate(invocation.chat_generations): - span.set_attribute(f"gen_ai.completion.{index}.content", chat_generation.content) - span.set_attribute(f"gen_ai.completion.{index}.role", chat_generation.type) - metric_attributes = _get_metric_attributes(request_model, response_model, GenAI.GenAiOperationNameValues.CHAT.value, system, framework) # Record token usage metrics From 6bed2df897ebbc453b2dcfccf43d472f2ada7428 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Tue, 19 Aug 2025 12:27:17 +0100 Subject: [PATCH 6/7] feat: bug fixes and improvements Signed-off-by: Pavan Sudheendra --- .../genai/sdk/decorators/__init__.py | 37 +-- .../genai/sdk/decorators/base.py | 89 +++++- .../genai/sdk/decorators/helpers.py | 278 ++++++++++++++++++ 3 files changed, 357 insertions(+), 47 deletions(-) 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 index 618a57cf27..22adddd140 100644 --- 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 @@ -18,9 +18,6 @@ def task( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, - protocol: Optional[str] = None, method_name: Optional[str] = None, tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, ) -> Callable[[F], F]: @@ -29,9 +26,6 @@ def decorator(target): if inspect.isclass(target): return entity_class( name=name, - description=description, - version=version, - protocol=protocol, method_name=method_name, tlp_span_kind=tlp_span_kind, )(target) @@ -39,9 +33,6 @@ def decorator(target): # Target is a function/method return entity_method( name=name, - description=description, - version=version, - protocol=protocol, tlp_span_kind=tlp_span_kind, )(target) return decorator @@ -49,9 +40,6 @@ def decorator(target): def workflow( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, - protocol: Optional[str] = None, method_name: Optional[str] = None, tlp_span_kind: Optional[ Union[ObserveSpanKindValues, str] @@ -62,9 +50,6 @@ def decorator(target): if inspect.isclass(target): return entity_class( name=name, - description=description, - version=version, - protocol=protocol, method_name=method_name, tlp_span_kind=tlp_span_kind, )(target) @@ -72,9 +57,6 @@ def decorator(target): # Target is a function/method return entity_method( name=name, - description=description, - version=version, - protocol=protocol, tlp_span_kind=tlp_span_kind, )(target) @@ -83,16 +65,10 @@ def decorator(target): def agent( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, - protocol: Optional[str] = None, method_name: Optional[str] = None, ) -> Callable[[F], F]: return workflow( name=name, - description=description, - version=version, - protocol=protocol, method_name=method_name, tlp_span_kind=ObserveSpanKindValues.AGENT, ) @@ -100,14 +76,10 @@ def agent( def tool( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, method_name: Optional[str] = None, ) -> Callable[[F], F]: return task( name=name, - description=description, - version=version, method_name=method_name, tlp_span_kind=ObserveSpanKindValues.TOOL, ) @@ -115,8 +87,7 @@ def tool( def llm( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, + model_name: Optional[str] = None, method_name: Optional[str] = None, ) -> Callable[[F], F]: def decorator(target): @@ -124,8 +95,7 @@ def decorator(target): if inspect.isclass(target): return entity_class( name=name, - description=description, - version=version, + model_name=model_name, method_name=method_name, tlp_span_kind=ObserveSpanKindValues.LLM, )(target) @@ -133,8 +103,7 @@ def decorator(target): # Target is a function/method return entity_method( name=name, - description=description, - version=version, + 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 index b59ccb289b..0ed6b7b46f 100644 --- 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 @@ -70,6 +70,10 @@ def should_emit_events() -> bool: 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" @@ -86,11 +90,15 @@ def _handle_llm_span_attributes(tlp_span_kind, args, kwargs, res=None): # Extract messages and attributes as before messages = _extract_messages_from_args_kwargs(args, kwargs) - # attributes = _extract_llm_attributes_from_args_kwargs(args, kwargs, res) + tool_functions = _extract_tool_functions_from_args_kwargs(args, kwargs) run_id = uuid4() try: - telemetry.start_llm(prompts=messages, run_id=run_id) + 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}") @@ -143,11 +151,73 @@ def _extract_messages_from_args_kwargs(args, kwargs): msg_type = "user" if msg_type == "human" else msg_type if content and msg_type: - messages.append(Message(content=str(content), type=str(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 = {} @@ -293,9 +363,7 @@ def _unwrap_structured_tool(fn): def entity_method( name: Optional[str] = None, - description: Optional[str] = None, - version: Optional[int] = None, - protocol: Optional[str] = None, + model_name: Optional[str] = None, tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, ) -> Callable[[F], F]: def decorate(fn: F) -> F: @@ -374,9 +442,7 @@ def sync_wrap(*args: Any, **kwargs: Any) -> Any: def entity_class( name: Optional[str], - description: Optional[str], - version: Optional[int], - protocol: Optional[str], + model_name: Optional[str], method_name: Optional[str], tlp_span_kind: Optional[ObserveSpanKindValues] = ObserveSpanKindValues.TASK, ): @@ -428,9 +494,7 @@ def decorator(cls): sig = inspect.signature(unwrapped_method) wrapped_method = entity_method( name=f"{task_name}.{method_to_wrap}", - description=description, - version=version, - protocol=protocol, + model_name=model_name, tlp_span_kind=tlp_span_kind, )(unwrapped_method) # Set the wrapped method on the class @@ -442,4 +506,3 @@ def decorator(cls): 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 index d97419622c..50e213b52f 100644 --- 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 @@ -61,3 +61,281 @@ def _get_original_function_name(fn): # 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 From 60c7a0064c38275bf370b1d2675b87c7d4dd46c0 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Thu, 28 Aug 2025 09:23:52 +0100 Subject: [PATCH 7/7] feat: remove Otel SDK references Signed-off-by: Pavan Sudheendra --- .../genai/sdk/decorators/base.py | 19 ----------- .../examples/decorator/main.py | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+), 19 deletions(-) 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 index 0ed6b7b46f..f79e4eb971 100644 --- 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 @@ -26,25 +26,6 @@ from opentelemetry.genai.sdk.api import get_telemetry_client -from opentelemetry import trace - -def _ensure_tracer_provider(): - # Only set a default TracerProvider if one isn't set - if type(trace.get_tracer_provider()).__name__ == "ProxyTracerProvider": - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - exporter_protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() - if exporter_protocol == "http" or exporter_protocol == "http/protobuf": - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - else: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - trace.set_tracer_provider(provider) - -_ensure_tracer_provider() - - P = ParamSpec("P") R = TypeVar("R") diff --git a/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py b/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py index 0ca74c50b5..12143c6cf2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/examples/decorator/main.py @@ -4,6 +4,39 @@ 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()