diff --git a/docs/integrations/llms/langchain.md b/docs/integrations/llms/langchain.md index c97759124..7475efa43 100644 --- a/docs/integrations/llms/langchain.md +++ b/docs/integrations/llms/langchain.md @@ -4,27 +4,21 @@ integration: built-in # LangChain -[LangChain](https://www.langchain.com/) (and thus [LangGraph](https://www.langchain.com/langgraph)) has [built-in OpenTelemetry tracing via Langsmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_langchain_with_otel) which you can use with **Logfire**. It's enabled by these two environment variables: +Logfire provides instrumentation for [LangChain](https://www.langchain.com/) and [LangGraph](https://www.langchain.com/langgraph) via `logfire.instrument_langchain()`. -``` -LANGSMITH_OTEL_ENABLED=true -LANGSMITH_TRACING=true -``` +## Installation -Here's a complete example using LangGraph: - -```python -import os +Install Logfire with the `langchain` extra: -import logfire +{{ install_logfire(extras=['langchain']) }} -# These environment variables need to be set before importing langchain or langgraph -os.environ['LANGSMITH_OTEL_ENABLED'] = 'true' -os.environ['LANGSMITH_TRACING'] = 'true' +## Usage -from langchain.agents import create_agent +```python +import logfire logfire.configure() +logfire.instrument_langchain() def add(a: float, b: float) -> float: @@ -33,7 +27,6 @@ def add(a: float, b: float) -> float: math_agent = create_agent('openai:gpt-4o', tools=[add], name='math_agent') - result = math_agent.invoke({'messages': [{'role': 'user', 'content': "what's 123 + 456?"}]}) print(result['messages'][-1].content) ``` diff --git a/logfire/__init__.py b/logfire/__init__.py index 2badb3b4d..08fb346e0 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -35,6 +35,7 @@ instrument_openai = DEFAULT_LOGFIRE_INSTANCE.instrument_openai instrument_openai_agents = DEFAULT_LOGFIRE_INSTANCE.instrument_openai_agents instrument_anthropic = DEFAULT_LOGFIRE_INSTANCE.instrument_anthropic +instrument_langchain = DEFAULT_LOGFIRE_INSTANCE.instrument_langchain instrument_google_genai = DEFAULT_LOGFIRE_INSTANCE.instrument_google_genai instrument_litellm = DEFAULT_LOGFIRE_INSTANCE.instrument_litellm instrument_print = DEFAULT_LOGFIRE_INSTANCE.instrument_print @@ -129,6 +130,7 @@ def loguru_handler() -> Any: 'instrument_openai', 'instrument_openai_agents', 'instrument_anthropic', + 'instrument_langchain', 'instrument_google_genai', 'instrument_litellm', 'instrument_print', diff --git a/logfire/_internal/exporters/processor_wrapper.py b/logfire/_internal/exporters/processor_wrapper.py index 21da6cff1..6782b5b37 100644 --- a/logfire/_internal/exporters/processor_wrapper.py +++ b/logfire/_internal/exporters/processor_wrapper.py @@ -328,6 +328,64 @@ def _tweak_fastapi_span(span: ReadableSpanDict): span['events'] = new_events[::-1] +def _normalize_content_block(block: dict[str, Any]) -> dict[str, Any]: + """Normalize a content block to OTel GenAI schema. + + Handles: + - Text: converts 'text' field to 'content' (OTel uses 'content') + - tool_use: converts to 'tool_call' (OTel standard) + - tool_result: converts to 'tool_call_response' (OTel standard) + """ + block_type = block.get('type', 'text') + + if block_type == 'text': + return { + 'type': 'text', + 'content': block.get('content', block.get('text', '')), + } + + if block_type == 'tool_use': + return { + 'type': 'tool_call', + 'id': block.get('id'), + 'name': block.get('name'), + 'arguments': block.get('input', block.get('arguments')), + } + + if block_type == 'tool_result': + return { + 'type': 'tool_call_response', + 'id': block.get('tool_use_id', block.get('id')), + 'response': block.get('content', block.get('response')), + } + + return block + + +def _convert_to_otel_message(msg: dict[str, Any]) -> dict[str, Any]: + """Convert a message dict to OTel GenAI message schema with role and parts.""" + otel_msg: dict[str, Any] = {'role': msg.get('role', 'user'), 'parts': []} + content = msg.get('content') + if content: + if isinstance(content, str): + otel_msg['parts'].append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + otel_msg['parts'].append(_normalize_content_block(block)) + if tool_calls := msg.get('tool_calls'): + for tc in tool_calls: + otel_msg['parts'].append( + { + 'type': 'tool_call', + 'id': tc.get('id'), + 'name': tc.get('function', {}).get('name') or tc.get('name'), + 'arguments': tc.get('function', {}).get('arguments') or tc.get('args'), + } + ) + return otel_msg + + def _transform_langchain_span(span: ReadableSpanDict): """Transform spans generated by LangSmith to work better in the Logfire UI. @@ -387,6 +445,18 @@ def _transform_langchain_span(span: ReadableSpanDict): # Remove gen_ai.system=langchain as this also interferes with costs in the UI. attributes = {k: v for k, v in attributes.items() if k != 'gen_ai.system'} + with suppress(Exception): + completion = parsed_attributes.get('gen_ai.completion', {}) + stop_reason = ( + completion.get('generations', [[{}]])[0][0] + .get('message', {}) + .get('kwargs', {}) + .get('response_metadata', {}) + .get('stop_reason') + ) + if stop_reason: + new_attributes['gen_ai.response.finish_reasons'] = json.dumps([stop_reason]) + # Add `all_messages_events` with suppress(Exception): input_messages = parsed_attributes.get('input.value', parsed_attributes.get('gen_ai.prompt', {}))['messages'] @@ -422,6 +492,32 @@ def _transform_langchain_span(span: ReadableSpanDict): new_attributes['all_messages_events'] = json.dumps(message_events) properties['all_messages_events'] = {'type': 'array'} + input_msgs = [] + output_msgs = [] + system_instructions = [] + for msg in message_events: + role = msg.get('role') + if role == 'system': + content = msg.get('content', '') + if isinstance(content, str): + system_instructions.append({'type': 'text', 'content': content}) + elif isinstance(content, list): + system_instructions.extend(content) + elif role == 'assistant': + output_msgs.append(_convert_to_otel_message(msg)) + else: + input_msgs.append(_convert_to_otel_message(msg)) + + if input_msgs: + new_attributes['gen_ai.input.messages'] = json.dumps(input_msgs) + properties['gen_ai.input.messages'] = {'type': 'array'} + if output_msgs: + new_attributes['gen_ai.output.messages'] = json.dumps(output_msgs) + properties['gen_ai.output.messages'] = {'type': 'array'} + if system_instructions: + new_attributes['gen_ai.system_instructions'] = json.dumps(system_instructions) + properties['gen_ai.system_instructions'] = {'type': 'array'} + span['attributes'] = { **attributes, ATTRIBUTES_JSON_SCHEMA_KEY: attributes_json_schema(properties), diff --git a/logfire/_internal/integrations/langchain.py b/logfire/_internal/integrations/langchain.py new file mode 100644 index 000000000..0f1c0d6a8 --- /dev/null +++ b/logfire/_internal/integrations/langchain.py @@ -0,0 +1,626 @@ +"""LangChain/LangGraph instrumentation for capturing tool definitions. + +This module provides callback-based instrumentation for LangChain that captures +tool definitions, which are not available through LangSmith's OTEL integration. +""" + +from __future__ import annotations + +import json +from contextlib import AbstractContextManager, contextmanager +from contextvars import Token +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from opentelemetry import context as context_api, trace +from opentelemetry.trace import SpanKind + +from .llm_providers.semconv import ( + CONVERSATION_ID, + INPUT_MESSAGES, + INPUT_TOKENS, + OPERATION_NAME, + OUTPUT_MESSAGES, + OUTPUT_TOKENS, + REQUEST_MODEL, + RESPONSE_FINISH_REASONS, + RESPONSE_MODEL, + SYSTEM_INSTRUCTIONS, + TOOL_DEFINITIONS, +) + +if TYPE_CHECKING: + from ..main import Logfire, LogfireSpan + + +try: + from langchain_core.callbacks.base import BaseCallbackHandler + + _BASE_CLASS = BaseCallbackHandler +except ImportError: + _BASE_CLASS = object + + +@dataclass +class SpanWithToken: + """Container for span and its context token.""" + + span: Any + token: Token | None = None + + +def _set_span_in_context(span: LogfireSpan) -> Token: + """Attach span to context and return token for later detachment.""" + otel_context = trace.set_span_in_context(span._span) + return context_api.attach(otel_context) + + +def _detach_span_from_context(token: Token) -> None: + """Detach span from context using token.""" + try: + context_api.detach(token) + except ValueError: + pass + + +def _normalize_content_block(block: dict[str, Any]) -> dict[str, Any]: + """Normalize a content block to OTel GenAI schema. + + Handles: + - Text: converts 'text' field to 'content' (OTel uses 'content') + - tool_use: converts to 'tool_call' (OTel standard) + - tool_result: converts to 'tool_call_response' (OTel standard) + """ + block_type = block.get('type', 'text') + + if block_type == 'text': + return { + 'type': 'text', + 'content': block.get('content', block.get('text', '')), + } + + if block_type == 'tool_use': + return { + 'type': 'tool_call', + 'id': block.get('id'), + 'name': block.get('name'), + 'arguments': block.get('input', block.get('arguments')), + } + + if block_type == 'tool_result': + return { + 'type': 'tool_call_response', + 'id': block.get('tool_use_id', block.get('id')), + 'response': block.get('content', block.get('response')), + } + + return block + + +class LogfireLangchainCallbackHandler(_BASE_CLASS): # type: ignore[misc] + """LangChain callback handler that captures full execution hierarchy. + + This handler captures: + - Chain execution (on_chain_start/end) + - Tool execution (on_tool_start/end) + - Retriever execution (on_retriever_start/end) + - LLM calls with tool definitions (on_chat_model_start/on_llm_start) + + Uses parent_run_id for hierarchy instead of context propagation. + """ + + def __init__(self, logfire: Logfire): + super().__init__() + self.run_inline = True + self._logfire = logfire + self._run_span_mapping: dict[str, SpanWithToken] = {} + + def _get_span_by_run_id(self, run_id: UUID) -> Any | None: + """Get span from run_id mapping.""" + if st := self._run_span_mapping.get(str(run_id)): + return st.span + return None + + def _get_parent_span(self, parent_run_id: UUID | None) -> Any | None: + """Get parent span from parent_run_id mapping.""" + if parent_run_id: + if st := self._run_span_mapping.get(str(parent_run_id)): + return st.span + return None + + def _get_span_name(self, serialized: dict[str, Any], default: str = 'unknown') -> str: + """Extract span name from serialized dict.""" + return serialized.get('name', serialized.get('id', [default])[-1]) + + def _extract_conversation_id(self, metadata: dict[str, Any] | None) -> str | None: + """Extract thread_id from metadata for gen_ai.conversation.id.""" + if metadata: + return metadata.get('thread_id') + return None + + def _start_span( + self, + span_name: str, + run_id: UUID, + parent_run_id: UUID | None = None, + span_kind: SpanKind = SpanKind.INTERNAL, + conversation_id: str | None = None, + **span_data: Any, + ) -> Any: + """Start a span with proper parent linkage using parent_run_id.""" + parent_span = self._get_parent_span(parent_run_id) + + parent_token = None + if parent_span and parent_span._span: + parent_context = trace.set_span_in_context(parent_span._span) + parent_token = context_api.attach(parent_context) + + try: + span = self._logfire.span( + span_name, + _span_kind=span_kind, + **span_data, + ) + span._start() + if conversation_id: + span.set_attribute(CONVERSATION_ID, conversation_id) + finally: + if parent_token is not None: + context_api.detach(parent_token) + + self._run_span_mapping[str(run_id)] = SpanWithToken(span, None) + return span + + def _end_span( + self, + run_id: UUID, + outputs: Any = None, + error: BaseException | None = None, + ) -> None: + """End span and clean up mapping.""" + st = self._run_span_mapping.pop(str(run_id), None) + if not st: + return + + try: + if error and st.span._span and st.span._span.is_recording(): + st.span._span.record_exception(error, escaped=True) + st.span._end() + finally: + if st.token: + _detach_span_from_context(st.token) + + def _extract_tool_definitions(self, kwargs: dict[str, Any]) -> list[dict[str, Any]]: + """Extract tool definitions from invocation_params.tools.""" + raw_tools = kwargs.get('invocation_params', {}).get('tools', []) + tools = [] + for raw_tool in raw_tools: + if raw_tool.get('type') == 'function': + tools.append(raw_tool) + elif 'name' in raw_tool: + tools.append( + { + 'type': 'function', + 'function': { + 'name': raw_tool.get('name'), + 'description': raw_tool.get('description'), + 'parameters': raw_tool.get('input_schema', raw_tool.get('parameters')), + }, + } + ) + return tools + + def _convert_messages_to_otel( + self, messages: list[list[Any]] + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Convert LangChain messages to OTel GenAI format.""" + input_msgs: list[dict[str, Any]] = [] + system_instructions: list[dict[str, Any]] = [] + + for msg_list in messages: + for msg in msg_list: + msg_type = getattr(msg, 'type', 'unknown') + content = getattr(msg, 'content', str(msg)) + + if msg_type == 'system': + if isinstance(content, str): + system_instructions.append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + if 'text' in item and 'content' not in item: + system_instructions.append({ + 'type': item.get('type', 'text'), + 'content': item['text'], + }) + else: + system_instructions.append(item) + elif isinstance(item, str): + system_instructions.append({'type': 'text', 'content': item}) + elif msg_type == 'tool': + tool_call_id = getattr(msg, 'tool_call_id', None) + response_content = content if isinstance(content, str) else str(content) + parts: list[dict[str, Any]] = [{ + 'type': 'tool_call_response', + 'id': tool_call_id, + 'response': response_content, + }] + input_msgs.append({'role': 'tool', 'parts': parts}) + else: + otel_role = {'human': 'user', 'ai': 'assistant'}.get(msg_type, msg_type) + parts = [] + + if isinstance(content, str): + parts.append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + if item.get('type') == 'tool_use': + continue + parts.append(_normalize_content_block(item)) + elif isinstance(item, str): + parts.append({'type': 'text', 'content': item}) + + if tool_calls := getattr(msg, 'tool_calls', None): + for tc in tool_calls: + if isinstance(tc, dict): + parts.append({ + 'type': 'tool_call', + 'id': tc.get('id'), + 'name': tc.get('name'), + 'arguments': tc.get('args'), + }) + + input_msgs.append({'role': otel_role, 'parts': parts}) + + return input_msgs, system_instructions + + def on_chain_start( + self, + serialized: dict[str, Any], + inputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + run_type: str | None = None, + name: str | None = None, + **kwargs: Any, + ) -> None: + """Called when a chain starts - creates parent span for hierarchy.""" + span_name = name or self._get_span_name(serialized, 'chain') + conversation_id = self._extract_conversation_id(metadata) + self._start_span(span_name, run_id, parent_run_id, SpanKind.INTERNAL, conversation_id=conversation_id) + + def on_chain_end( + self, + outputs: dict[str, Any], + *, + run_id: UUID, + inputs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Called when a chain ends.""" + self._end_span(run_id) + + def on_chain_error( + self, + error: BaseException, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when a chain errors.""" + self._end_span(run_id, error=error) + + def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inputs: dict[str, Any] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> None: + """Called when a tool starts.""" + span_name = name or self._get_span_name(serialized, 'tool') + conversation_id = self._extract_conversation_id(metadata) + self._start_span(span_name, run_id, parent_run_id, SpanKind.INTERNAL, conversation_id=conversation_id) + + def on_tool_end( + self, + output: Any, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when a tool ends.""" + self._end_span(run_id) + + def on_tool_error( + self, + error: BaseException, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when a tool errors.""" + self._end_span(run_id, error=error) + + def on_retriever_start( + self, + serialized: dict[str, Any], + query: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> None: + """Called when a retriever starts.""" + span_name = name or self._get_span_name(serialized, 'retriever') + conversation_id = self._extract_conversation_id(metadata) + self._start_span(span_name, run_id, parent_run_id, SpanKind.INTERNAL, conversation_id=conversation_id) + + def on_retriever_end( + self, + documents: Any, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when a retriever ends.""" + self._end_span(run_id) + + def on_retriever_error( + self, + error: BaseException, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when a retriever errors.""" + self._end_span(run_id, error=error) + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[Any]], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> None: + """Called when a chat model starts - captures tool definitions.""" + invocation_params = kwargs.get('invocation_params', {}) + model = invocation_params.get('model', invocation_params.get('model_name', 'unknown')) + + span_name = name or self._get_span_name(serialized, f'chat {model}') + span_data: dict[str, Any] = { + OPERATION_NAME: 'chat', + REQUEST_MODEL: model, + } + + if tools := self._extract_tool_definitions(kwargs): + span_data[TOOL_DEFINITIONS] = tools + + try: + input_msgs, system_instructions = self._convert_messages_to_otel(messages) + if input_msgs: + span_data[INPUT_MESSAGES] = input_msgs + if system_instructions: + span_data[SYSTEM_INSTRUCTIONS] = system_instructions + except Exception: + pass + + conversation_id = self._extract_conversation_id(metadata) + self._start_span(span_name, run_id, parent_run_id, SpanKind.CLIENT, conversation_id=conversation_id, **span_data) + + def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> None: + """Called when a non-chat LLM starts.""" + invocation_params = kwargs.get('invocation_params', {}) + model = invocation_params.get('model', invocation_params.get('model_name', 'unknown')) + + span_name = name or self._get_span_name(serialized, f'llm {model}') + span_data: dict[str, Any] = { + OPERATION_NAME: 'completion', + REQUEST_MODEL: model, + } + + if tools := self._extract_tool_definitions(kwargs): + span_data[TOOL_DEFINITIONS] = tools + + conversation_id = self._extract_conversation_id(metadata) + self._start_span(span_name, run_id, parent_run_id, SpanKind.CLIENT, conversation_id=conversation_id, **span_data) + + def on_llm_end( + self, + response: Any, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when LLM ends.""" + span = self._get_span_by_run_id(run_id) + if not span: + return + + try: + generations = getattr(response, 'generations', [[]]) + if generations and generations[0]: + gen = generations[0][0] + message = getattr(gen, 'message', None) + if message: + response_metadata = getattr(message, 'response_metadata', {}) or {} + if stop_reason := response_metadata.get('stop_reason', response_metadata.get('finish_reason')): + span.set_attribute(RESPONSE_FINISH_REASONS, json.dumps([stop_reason])) + + if model_name := response_metadata.get('model_name', response_metadata.get('model')): + span.set_attribute(RESPONSE_MODEL, model_name) + + content = getattr(message, 'content', '') + output_msg: dict[str, Any] = {'role': 'assistant', 'parts': []} + + if isinstance(content, str): + output_msg['parts'].append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + if item.get('type') == 'tool_use': + continue + output_msg['parts'].append(_normalize_content_block(item)) + elif isinstance(item, str): + output_msg['parts'].append({'type': 'text', 'content': item}) + + if tool_calls := getattr(message, 'tool_calls', None): + for tc in tool_calls: + if isinstance(tc, dict): + output_msg['parts'].append( + { + 'type': 'tool_call', + 'id': tc.get('id'), + 'name': tc.get('name'), + 'arguments': tc.get('args'), + } + ) + + span.set_attribute(OUTPUT_MESSAGES, [output_msg]) + + llm_output = getattr(response, 'llm_output', {}) or {} + usage = llm_output.get('usage') or llm_output.get('token_usage') or {} + if input_tokens := usage.get('input_tokens', usage.get('prompt_tokens')): + span.set_attribute(INPUT_TOKENS, input_tokens) + if output_tokens := usage.get('output_tokens', usage.get('completion_tokens')): + span.set_attribute(OUTPUT_TOKENS, output_tokens) + except Exception: + pass + finally: + self._end_span(run_id) + + def on_llm_error( + self, + error: BaseException, + *, + run_id: UUID, + **kwargs: Any, + ) -> None: + """Called when LLM errors.""" + self._end_span(run_id, error=error) + + +_original_callback_manager_init: Any = None +_logfire_instance: Logfire | None = None +_handler_instance: LogfireLangchainCallbackHandler | None = None + + +def _patch_callback_manager(logfire: Logfire) -> None: + """Patch BaseCallbackManager to inject our handler.""" + global _original_callback_manager_init, _logfire_instance, _handler_instance + + try: + from langchain_core.callbacks import BaseCallbackManager + except ImportError as e: + raise ImportError( + 'langchain-core is required for LangChain instrumentation. Install it with: pip install langchain-core' + ) from e + + if _original_callback_manager_init is not None: + return + + _logfire_instance = logfire + _handler_instance = None + _original_callback_manager_init = BaseCallbackManager.__init__ + + def patched_init(self: Any, *args: Any, **kwargs: Any) -> None: + global _handler_instance + _original_callback_manager_init(self, *args, **kwargs) + + for handler in list(getattr(self, 'handlers', [])) + list(getattr(self, 'inheritable_handlers', [])): + if isinstance(handler, LogfireLangchainCallbackHandler): + return + + if _logfire_instance is not None: + if _handler_instance is None: + _handler_instance = LogfireLangchainCallbackHandler(_logfire_instance) + self.add_handler(_handler_instance, inherit=True) + + BaseCallbackManager.__init__ = patched_init + + +def _unpatch_callback_manager() -> None: + """Restore original BaseCallbackManager.__init__.""" + global _original_callback_manager_init, _logfire_instance, _handler_instance + + if _original_callback_manager_init is None: + return + + try: + from langchain_core.callbacks import BaseCallbackManager + + BaseCallbackManager.__init__ = _original_callback_manager_init + except ImportError: + pass + + _original_callback_manager_init = None + _logfire_instance = None + _handler_instance = None + + +def instrument_langchain(logfire: Logfire) -> AbstractContextManager[None]: + """Instrument LangChain to capture full execution hierarchy. + + This patches LangChain's BaseCallbackManager to inject a callback handler + that captures the complete execution hierarchy including chains, tools, + retrievers, and LLMs with tool definitions. + + The patching happens immediately when this function is called. + Returns a context manager that can be used to uninstrument if needed. + + Args: + logfire: The Logfire instance to use for creating spans. + + Returns: + A context manager for optional cleanup/uninstrumentation. + + Example: + ```python + import logfire + + logfire.configure() + logfire.instrument_langchain() + + # Now LangChain operations will be traced with full hierarchy + ``` + """ + _patch_callback_manager(logfire) + + @contextmanager + def cleanup_context(): + try: + yield + finally: + _unpatch_callback_manager() + + return cleanup_context() diff --git a/logfire/_internal/integrations/llm_providers/anthropic.py b/logfire/_internal/integrations/llm_providers/anthropic.py index 0c356421b..742011353 100644 --- a/logfire/_internal/integrations/llm_providers/anthropic.py +++ b/logfire/_internal/integrations/llm_providers/anthropic.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any, cast import anthropic @@ -14,11 +15,17 @@ OUTPUT_MESSAGES, OUTPUT_TOKENS, PROVIDER_NAME, + REQUEST_MAX_TOKENS, REQUEST_MODEL, + REQUEST_STOP_SEQUENCES, + REQUEST_TEMPERATURE, + REQUEST_TOP_K, + REQUEST_TOP_P, RESPONSE_FINISH_REASONS, RESPONSE_ID, RESPONSE_MODEL, SYSTEM_INSTRUCTIONS, + TOOL_DEFINITIONS, ) from .types import EndpointConfig, StreamState @@ -35,6 +42,27 @@ ) +def _extract_request_parameters(json_data: dict[str, Any], span_data: dict[str, Any]) -> None: + """Extract request parameters from json_data and add to span_data.""" + if (max_tokens := json_data.get('max_tokens')) is not None: + span_data[REQUEST_MAX_TOKENS] = max_tokens + + if (temperature := json_data.get('temperature')) is not None: + span_data[REQUEST_TEMPERATURE] = temperature + + if (top_p := json_data.get('top_p')) is not None: + span_data[REQUEST_TOP_P] = top_p + + if (top_k := json_data.get('top_k')) is not None: + span_data[REQUEST_TOP_K] = top_k + + if (stop_sequences := json_data.get('stop_sequences')) is not None: + span_data[REQUEST_STOP_SEQUENCES] = json.dumps(stop_sequences) + + if (tools := json_data.get('tools')) is not None: + span_data[TOOL_DEFINITIONS] = json.dumps(tools) + + def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: """Returns the endpoint config for Anthropic or Bedrock depending on the url.""" url = options.url @@ -52,6 +80,9 @@ def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: REQUEST_MODEL: json_data.get('model'), } + # Extract request parameters + _extract_request_parameters(json_data, span_data) + # Convert messages to semantic convention format messages: list[dict[str, Any]] = json_data.get('messages', []) system: str | list[dict[str, Any]] | None = json_data.get('system') @@ -115,6 +146,9 @@ def convert_anthropic_messages_to_semconv( for part in cast('list[dict[str, Any] | str]', content): parts.append(_convert_anthropic_content_part(part)) + if any(p.get('type') == 'tool_call_response' for p in parts): + role = 'tool' + input_messages.append( { 'role': role, diff --git a/logfire/_internal/integrations/llm_providers/llm_provider.py b/logfire/_internal/integrations/llm_providers/llm_provider.py index 1514ca2bb..54968af93 100644 --- a/logfire/_internal/integrations/llm_providers/llm_provider.py +++ b/logfire/_internal/integrations/llm_providers/llm_provider.py @@ -4,11 +4,14 @@ from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext from typing import TYPE_CHECKING, Any, Callable, cast +from opentelemetry.trace import SpanKind + from logfire import attach_context, get_context from logfire.propagate import ContextCarrier from ...constants import ONE_SECOND_IN_NANOSECONDS from ...utils import is_instrumentation_suppressed, log_internal_error, suppress_instrumentation +from .semconv import OPERATION_NAME, REQUEST_MODEL if TYPE_CHECKING: from ...main import Logfire, LogfireSpan @@ -132,11 +135,20 @@ def __stream__(self) -> Iterator[Any]: # In these methods, `*args` is only expected to be `(self,)` # in the case where we instrument classes rather than client instances. + def _get_otel_span_name(span_data: dict[str, Any]) -> str | None: + """Construct OTel-compliant span name as '{operation} {model}'.""" + operation = span_data.get(OPERATION_NAME) + model = span_data.get(REQUEST_MODEL) + if operation and model: + return f'{operation} {model}' + return None + def instrumented_llm_request_sync(*args: Any, **kwargs: Any) -> Any: message_template, span_data, kwargs = _instrumentation_setup(*args, **kwargs) if message_template is None: return original_request_method(*args, **kwargs) - with logfire_llm.span(message_template, **span_data) as span: + span_name = _get_otel_span_name(span_data) + with logfire_llm.span(message_template, _span_kind=SpanKind.CLIENT, _span_name=span_name, **span_data) as span: with maybe_suppress_instrumentation(suppress_otel): if kwargs.get('stream'): return original_request_method(*args, **kwargs) @@ -148,7 +160,8 @@ async def instrumented_llm_request_async(*args: Any, **kwargs: Any) -> Any: message_template, span_data, kwargs = _instrumentation_setup(*args, **kwargs) if message_template is None: return await original_request_method(*args, **kwargs) - with logfire_llm.span(message_template, **span_data) as span: + span_name = _get_otel_span_name(span_data) + with logfire_llm.span(message_template, _span_kind=SpanKind.CLIENT, _span_name=span_name, **span_data) as span: with maybe_suppress_instrumentation(suppress_otel): if kwargs.get('stream'): return await original_request_method(*args, **kwargs) diff --git a/logfire/_internal/integrations/llm_providers/openai.py b/logfire/_internal/integrations/llm_providers/openai.py index d3a9d0b36..d2e6714d7 100644 --- a/logfire/_internal/integrations/llm_providers/openai.py +++ b/logfire/_internal/integrations/llm_providers/openai.py @@ -25,11 +25,19 @@ OUTPUT_MESSAGES, OUTPUT_TOKENS, PROVIDER_NAME, + REQUEST_FREQUENCY_PENALTY, + REQUEST_MAX_TOKENS, REQUEST_MODEL, + REQUEST_PRESENCE_PENALTY, + REQUEST_SEED, + REQUEST_STOP_SEQUENCES, + REQUEST_TEMPERATURE, + REQUEST_TOP_P, RESPONSE_FINISH_REASONS, RESPONSE_ID, RESPONSE_MODEL, SYSTEM_INSTRUCTIONS, + TOOL_DEFINITIONS, ) from .types import EndpointConfig, StreamState @@ -46,6 +54,38 @@ ) +def _extract_request_parameters(json_data: dict[str, Any], span_data: dict[str, Any]) -> None: + """Extract request parameters from json_data and add to span_data.""" + if (max_tokens := json_data.get('max_tokens')) is not None: + span_data[REQUEST_MAX_TOKENS] = max_tokens + elif (max_output_tokens := json_data.get('max_output_tokens')) is not None: + span_data[REQUEST_MAX_TOKENS] = max_output_tokens + + if (temperature := json_data.get('temperature')) is not None: + span_data[REQUEST_TEMPERATURE] = temperature + + if (top_p := json_data.get('top_p')) is not None: + span_data[REQUEST_TOP_P] = top_p + + if (stop := json_data.get('stop')) is not None: + if isinstance(stop, str): + span_data[REQUEST_STOP_SEQUENCES] = json.dumps([stop]) + else: + span_data[REQUEST_STOP_SEQUENCES] = json.dumps(stop) + + if (seed := json_data.get('seed')) is not None: + span_data[REQUEST_SEED] = seed + + if (frequency_penalty := json_data.get('frequency_penalty')) is not None: + span_data[REQUEST_FREQUENCY_PENALTY] = frequency_penalty + + if (presence_penalty := json_data.get('presence_penalty')) is not None: + span_data[REQUEST_PRESENCE_PENALTY] = presence_penalty + + if (tools := json_data.get('tools')) is not None: + span_data[TOOL_DEFINITIONS] = json.dumps(tools) + + def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: """Returns the endpoint config for OpenAI depending on the url.""" url = options.url @@ -66,7 +106,8 @@ def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: OPERATION_NAME: 'chat', REQUEST_MODEL: json_data.get('model'), } - # Convert messages to semantic convention format + _extract_request_parameters(json_data, span_data) + messages: list[dict[str, Any]] = json_data.get('messages', []) if messages: input_messages, system_instructions = convert_openai_messages_to_semconv(messages) @@ -89,11 +130,17 @@ def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: OPERATION_NAME: 'chat', REQUEST_MODEL: json_data.get('model'), 'request_data': {'model': json_data.get('model'), 'stream': stream}, - 'events': inputs_to_events( - json_data.get('input'), - json_data.get('instructions'), - ), } + # Extract request parameters + _extract_request_parameters(json_data, span_data) + + input_messages, system_instructions = convert_responses_inputs_to_semconv( + json_data.get('input'), json_data.get('instructions') + ) + if input_messages: + span_data[INPUT_MESSAGES] = input_messages + if system_instructions: + span_data[SYSTEM_INSTRUCTIONS] = system_instructions return EndpointConfig( message_template='Responses API with {request_data[model]!r}', @@ -275,6 +322,91 @@ def convert_openai_response_to_semconv( return result +def convert_responses_inputs_to_semconv( + inputs: str | list[dict[str, Any]] | None, instructions: str | None +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Convert Responses API inputs to OTel Gen AI Semantic Convention format.""" + input_messages: list[dict[str, Any]] = [] + system_instructions: list[dict[str, Any]] = [] + if instructions: + system_instructions.append({'type': 'text', 'content': instructions}) + if inputs: + if isinstance(inputs, str): + input_messages.append({'role': 'user', 'parts': [{'type': 'text', 'content': inputs}]}) + else: + for inp in inputs: + role, typ, content = inp.get('role', 'user'), inp.get('type'), inp.get('content') + if typ in (None, 'message') and content: + parts: list[dict[str, Any]] = [] + if isinstance(content, str): + parts.append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'output_text': + parts.append({'type': 'text', 'content': item.get('text', '')}) + else: + parts.append(item if isinstance(item, dict) else {'type': 'text', 'content': str(item)}) + input_messages.append({'role': role, 'parts': parts}) + elif typ == 'function_call': + input_messages.append( + { + 'role': 'assistant', + 'parts': [ + { + 'type': 'tool_call', + 'id': inp.get('call_id'), + 'name': inp.get('name'), + 'arguments': inp.get('arguments'), + } + ], + } + ) + elif typ == 'function_call_output': + msg = { + 'role': 'tool', + 'parts': [ + {'type': 'tool_call_response', 'id': inp.get('call_id'), 'response': inp.get('output')} + ], + } + if 'name' in inp: + msg['name'] = inp['name'] + input_messages.append(msg) + return input_messages, system_instructions + + +def convert_responses_outputs_to_semconv(response: Response) -> list[dict[str, Any]]: + """Convert Responses API outputs to OTel Gen AI Semantic Convention format.""" + output_messages: list[dict[str, Any]] = [] + for out in response.output: + out_dict, typ, content = out.model_dump(), out.model_dump().get('type'), out.model_dump().get('content') + if typ in (None, 'message') and content: + parts: list[dict[str, Any]] = [] + if isinstance(content, str): + parts.append({'type': 'text', 'content': content}) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'output_text': + parts.append({'type': 'text', 'content': item.get('text', '')}) + else: + parts.append(item if isinstance(item, dict) else {'type': 'text', 'content': str(item)}) + output_messages.append({'role': 'assistant', 'parts': parts}) + elif typ == 'function_call': + output_messages.append( + { + 'role': 'assistant', + 'parts': [ + { + 'type': 'tool_call', + 'id': out_dict.get('call_id'), + 'name': out_dict.get('name'), + 'arguments': out_dict.get('arguments'), + } + ], + } + ) + return output_messages + + def is_current_agent_span(*span_names: str): current_span = get_current_span() return ( @@ -320,7 +452,9 @@ def get_response_data(self) -> Any: def get_attributes(self, span_data: dict[str, Any]) -> dict[str, Any]: response = self.get_response_data() - span_data['events'] = span_data['events'] + responses_output_events(response) + output_messages = convert_responses_outputs_to_semconv(response) + if output_messages: + span_data[OUTPUT_MESSAGES] = output_messages return span_data @@ -362,9 +496,6 @@ def on_response(response: ResponseT, span: LogfireSpan) -> ResponseT: on_response(response.parse(), span) # type: ignore return cast('ResponseT', response) - # Keep gen_ai.system for backward compatibility - span.set_attribute('gen_ai.system', 'openai') - if isinstance(response_model := getattr(response, 'model', None), str): span.set_attribute(RESPONSE_MODEL, response_model) @@ -442,13 +573,20 @@ def on_response(response: ResponseT, span: LogfireSpan) -> ResponseT: elif isinstance(response, ImagesResponse): span.set_attribute('response_data', {'images': response.data}) elif isinstance(response, Response): # pragma: no branch - try: - events = json.loads(span.attributes['events']) # type: ignore - except Exception: - pass - else: - events += responses_output_events(response) - span.set_attribute('events', events) + output_messages = convert_responses_outputs_to_semconv(response) + if output_messages: + span.set_attribute(OUTPUT_MESSAGES, output_messages) + + status = getattr(response, 'status', None) + if status: + status_to_finish_reason = { + 'completed': 'stop', + 'failed': 'error', + 'cancelled': 'cancelled', + 'incomplete': 'length', + } + finish_reason = status_to_finish_reason.get(status, status) + span.set_attribute(RESPONSE_FINISH_REASONS, [finish_reason]) return response diff --git a/logfire/_internal/integrations/llm_providers/semconv.py b/logfire/_internal/integrations/llm_providers/semconv.py index 0855237ab..1f89909f3 100644 --- a/logfire/_internal/integrations/llm_providers/semconv.py +++ b/logfire/_internal/integrations/llm_providers/semconv.py @@ -14,6 +14,16 @@ REQUEST_MODEL = 'gen_ai.request.model' RESPONSE_MODEL = 'gen_ai.response.model' +# Request parameters +REQUEST_MAX_TOKENS = 'gen_ai.request.max_tokens' +REQUEST_TEMPERATURE = 'gen_ai.request.temperature' +REQUEST_TOP_P = 'gen_ai.request.top_p' +REQUEST_TOP_K = 'gen_ai.request.top_k' +REQUEST_STOP_SEQUENCES = 'gen_ai.request.stop_sequences' +REQUEST_SEED = 'gen_ai.request.seed' +REQUEST_FREQUENCY_PENALTY = 'gen_ai.request.frequency_penalty' +REQUEST_PRESENCE_PENALTY = 'gen_ai.request.presence_penalty' + # Response metadata RESPONSE_ID = 'gen_ai.response.id' RESPONSE_FINISH_REASONS = 'gen_ai.response.finish_reasons' @@ -26,3 +36,9 @@ INPUT_MESSAGES = 'gen_ai.input.messages' OUTPUT_MESSAGES = 'gen_ai.output.messages' SYSTEM_INSTRUCTIONS = 'gen_ai.system_instructions' + +# Tool definitions +TOOL_DEFINITIONS = 'gen_ai.tool.definitions' + +# Conversation tracking +CONVERSATION_ID = 'gen_ai.conversation.id' diff --git a/logfire/_internal/integrations/openai_agents.py b/logfire/_internal/integrations/openai_agents.py index ea7f1569c..fcfddd43b 100644 --- a/logfire/_internal/integrations/openai_agents.py +++ b/logfire/_internal/integrations/openai_agents.py @@ -2,6 +2,7 @@ import contextvars import inspect +import json import sys from abc import abstractmethod from contextlib import nullcontext @@ -31,11 +32,24 @@ from agents.tracing.scope import Scope from agents.tracing.spans import NoOpSpan, SpanError, TSpanData from agents.tracing.traces import NoOpTrace -from opentelemetry.trace import NonRecordingSpan, use_span +from opentelemetry.trace import NonRecordingSpan, SpanKind, use_span from typing_extensions import Self from logfire._internal.formatter import logfire_format -from logfire._internal.integrations.llm_providers.openai import inputs_to_events, responses_output_events +from logfire._internal.integrations.llm_providers.openai import ( + convert_responses_inputs_to_semconv, + convert_responses_outputs_to_semconv, + inputs_to_events, + responses_output_events, +) +from logfire._internal.integrations.llm_providers.semconv import ( + INPUT_MESSAGES, + OUTPUT_MESSAGES, + PROVIDER_NAME, + RESPONSE_FINISH_REASONS, + SYSTEM_INSTRUCTIONS, + TOOL_DEFINITIONS, +) from logfire._internal.scrubbing import NOOP_SCRUBBER from logfire._internal.utils import handle_internal_errors, log_internal_error, truncate_string @@ -85,17 +99,23 @@ def create_span( return span extra_attributes: dict[str, Any] = {} + span_kind: SpanKind | None = None + otel_span_name: str | None = None + if isinstance(span_data, AgentSpanData): msg_template = 'Agent run: {name!r}' elif isinstance(span_data, FunctionSpanData): msg_template = 'Function: {name}' elif isinstance(span_data, GenerationSpanData): msg_template = 'Chat completion with {gen_ai.request.model!r}' + span_kind = SpanKind.CLIENT elif isinstance(span_data, ResponseSpanData): msg_template = 'Responses API' extra_attributes = get_magic_response_attributes() + span_kind = SpanKind.CLIENT if 'gen_ai.request.model' in extra_attributes: # pragma: no branch msg_template += ' with {gen_ai.request.model!r}' + otel_span_name = f"chat {extra_attributes['gen_ai.request.model']}" elif isinstance(span_data, GuardrailSpanData): msg_template = 'Guardrail {name!r} {triggered=}' elif isinstance(span_data, HandoffSpanData): @@ -118,6 +138,8 @@ def create_span( **attributes_from_span_data(span_data, msg_template), **extra_attributes, _tags=['LLM'] * isinstance(span_data, GenerationSpanData), + _span_kind=span_kind, + _span_name=otel_span_name, ) helper = LogfireSpanHelper(logfire_span, parent) return LogfireSpanWrapper(span, helper) @@ -347,6 +369,7 @@ def attributes_from_span_data(span_data: SpanData, msg_template: str) -> dict[st if '{type}' not in msg_template and attributes.get('type') == span_data.type: del attributes['type'] attributes['gen_ai.system'] = 'openai' + attributes[PROVIDER_NAME] = 'openai' if isinstance(attributes.get('model'), str): attributes['gen_ai.request.model'] = attributes['gen_ai.response.model'] = attributes.pop('model') if isinstance(span_data, ResponseSpanData): @@ -359,6 +382,49 @@ def attributes_from_span_data(span_data: SpanData, msg_template: str) -> dict[st if (usage := getattr(span_data.response, 'usage', None)) and getattr(usage, 'total_tokens', None): attributes['gen_ai.usage.input_tokens'] = usage.input_tokens attributes['gen_ai.usage.output_tokens'] = usage.output_tokens + + response = span_data.response + inputs: str | list[dict[str, Any]] | None = span_data.input # type: ignore + instructions = getattr(response, 'instructions', None) if response else None + + input_messages, system_instructions = convert_responses_inputs_to_semconv(inputs, instructions) + if input_messages: + attributes[INPUT_MESSAGES] = json.dumps(input_messages) + if system_instructions: + attributes[SYSTEM_INSTRUCTIONS] = json.dumps(system_instructions) + + if response: + output_messages = convert_responses_outputs_to_semconv(response) + if output_messages: + attributes[OUTPUT_MESSAGES] = json.dumps(output_messages) + + status = getattr(response, 'status', None) + if status: + status_to_finish_reason = { + 'completed': 'stop', + 'failed': 'error', + 'cancelled': 'cancelled', + 'incomplete': 'length', + } + finish_reason = status_to_finish_reason.get(status, status) + attributes[RESPONSE_FINISH_REASONS] = json.dumps([finish_reason]) + + if response and hasattr(response, 'tools') and response.tools: + tool_defs = [] + for tool in response.tools: + if hasattr(tool, 'name'): + tool_def: dict[str, Any] = { + 'type': getattr(tool, 'type', 'function'), + 'name': tool.name, + } + if hasattr(tool, 'description') and tool.description: + tool_def['description'] = tool.description + if hasattr(tool, 'parameters') and tool.parameters: + tool_def['parameters'] = tool.parameters + tool_defs.append(tool_def) + if tool_defs: + attributes[TOOL_DEFINITIONS] = json.dumps(tool_defs) + elif isinstance(span_data, GenerationSpanData): attributes['request_data'] = dict( messages=list(span_data.input or []) + list(span_data.output or []), model=span_data.model @@ -383,6 +449,7 @@ def get_basic_response_attributes(response: Response): 'gen_ai.response.model': getattr(response, 'model', None), 'response': response, 'gen_ai.system': 'openai', + PROVIDER_NAME: 'openai', 'gen_ai.operation.name': 'chat', } diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 0cfc1fc1e..ee8aba841 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -26,7 +26,7 @@ from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Counter, Histogram, UpDownCounter from opentelemetry.sdk.trace import ReadableSpan, Span -from opentelemetry.trace import SpanContext +from opentelemetry.trace import SpanContext, SpanKind from opentelemetry.util import types as otel_types from typing_extensions import LiteralString, ParamSpec @@ -186,6 +186,7 @@ def _span( _span_name: str | None = None, _level: LevelName | int | None = None, _links: Sequence[tuple[SpanContext, otel_types.Attributes]] = (), + _span_kind: SpanKind | None = None, ) -> LogfireSpan: try: if _level is not None: @@ -241,6 +242,7 @@ def _span( self._spans_tracer, json_schema_properties, links=_links, + span_kind=_span_kind, ) except Exception: log_internal_error() @@ -538,6 +540,7 @@ def span( _span_name: str | None = None, _level: LevelName | None = None, _links: Sequence[tuple[SpanContext, otel_types.Attributes]] = (), + _span_kind: SpanKind | None = None, **attributes: Any, ) -> LogfireSpan: """Context manager for creating a span. @@ -557,6 +560,7 @@ def span( _tags: An optional sequence of tags to include in the span. _level: An optional log level name. _links: An optional sequence of links to other spans. Each link is a tuple of a span context and attributes. + _span_kind: The span kind. If not provided, defaults to INTERNAL. attributes: The arguments to include in the span and format the message template with. Attributes starting with an underscore are not allowed. """ @@ -569,6 +573,7 @@ def span( _span_name=_span_name, _level=_level, _links=_links, + _span_kind=_span_kind, ) @overload @@ -1318,6 +1323,38 @@ def instrument_anthropic( is_async_client, ) + def instrument_langchain(self) -> AbstractContextManager[None]: + """Instrument LangChain/LangGraph to capture tool definitions. + + This patches LangChain's callback system to capture: + - Tool definitions (gen_ai.tool.definitions) + - Input/output messages in OTel GenAI format + - Token usage + - Model information + + Note: This works alongside LangSmith's OTEL integration but adds + tool definitions which LangSmith doesn't send via OTEL. + + Returns: + A context manager that can be used to uninstrument. + + Example: + ```python + import logfire + from langgraph.prebuilt import create_react_agent + + logfire.configure() + logfire.instrument_langchain() + + agent = create_react_agent(model, tools) + agent.invoke({'messages': [...]}) + ``` + """ + from .integrations.langchain import instrument_langchain as _instrument + + self._warn_if_not_initialized_for_instrumentation() + return _instrument(self) + def instrument_google_genai(self, **kwargs: Any): """Instrument the [Google Gen AI SDK (`google-genai`)](https://googleapis.github.io/python-genai/). @@ -2365,12 +2402,14 @@ def __init__( tracer: _ProxyTracer, json_schema_properties: JsonSchemaProperties, links: Sequence[tuple[SpanContext, otel_types.Attributes]], + span_kind: SpanKind | None = None, ) -> None: self._span_name = span_name self._otlp_attributes = otlp_attributes self._tracer = tracer self._json_schema_properties = json_schema_properties self._links = list(trace_api.Link(context=context, attributes=attributes) for context, attributes in links) + self._span_kind = span_kind self._added_attributes = False self._token: None | Token[Context] = None @@ -2385,11 +2424,14 @@ def __getattr__(self, name: str) -> Any: def _start(self): if self._span is not None: return - self._span = self._tracer.start_span( - name=self._span_name, - attributes=self._otlp_attributes, - links=self._links, - ) + kwargs: dict[str, Any] = { + 'name': self._span_name, + 'attributes': self._otlp_attributes, + 'links': self._links, + } + if self._span_kind is not None: + kwargs['kind'] = self._span_kind + self._span = self._tracer.start_span(**kwargs) @handle_internal_errors def _attach(self): diff --git a/tests/otel_integrations/test_anthropic.py b/tests/otel_integrations/test_anthropic.py index 237b91649..3a3082777 100644 --- a/tests/otel_integrations/test_anthropic.py +++ b/tests/otel_integrations/test_anthropic.py @@ -141,7 +141,7 @@ def test_sync_messages(instrumented_client: anthropic.Anthropic, exporter: TestE assert exporter.exported_spans_as_dict() == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -163,9 +163,12 @@ def test_sync_messages(instrumented_client: anthropic.Anthropic, exporter: TestE 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'async': False, + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", 'logfire.span_type': 'span', @@ -205,9 +208,12 @@ def test_sync_messages(instrumented_client: anthropic.Anthropic, exporter: TestE 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, + 'gen_ai.request.max_tokens': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.address': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -245,7 +251,7 @@ async def test_async_messages(instrumented_async_client: anthropic.AsyncAnthropi assert exporter.exported_spans_as_dict() == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -265,9 +271,11 @@ async def test_async_messages(instrumented_async_client: anthropic.AsyncAnthropi 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'async': True, + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", 'logfire.span_type': 'span', @@ -306,9 +314,11 @@ async def test_async_messages(instrumented_async_client: anthropic.AsyncAnthropi 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, + 'gen_ai.request.max_tokens': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -346,7 +356,7 @@ def test_sync_message_empty_response_chunk(instrumented_client: anthropic.Anthro assert exporter.exported_spans_as_dict() == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -359,12 +369,15 @@ def test_sync_message_empty_response_chunk(instrumented_client: anthropic.Anthro 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[]', 'gen_ai.system_instructions': '[{"type":"text","content":"empty response chunk"}]', 'async': False, + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", - 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{}}}', + 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.address":{},"server.port":{}}}', 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), 'gen_ai.response.model': 'claude-3-haiku-20240307', @@ -389,12 +402,15 @@ def test_sync_message_empty_response_chunk(instrumented_client: anthropic.Anthro 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[]', 'gen_ai.system_instructions': '[{"type":"text","content":"empty response chunk"}]', 'logfire.tags': ('LLM',), + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'duration': 1.0, 'response_data': '{"combined_chunk_content":"","chunk_count":0}', - 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"response_data":{"type":"object"}}}', + 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.address":{},"server.port":{},"response_data":{"type":"object"}}}', 'gen_ai.response.model': 'claude-3-haiku-20240307', }, }, @@ -420,7 +436,7 @@ def test_sync_messages_stream(instrumented_client: anthropic.Anthropic, exporter assert exporter.exported_spans_as_dict() == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -433,12 +449,15 @@ def test_sync_messages_stream(instrumented_client: anthropic.Anthropic, exporter 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'async': False, + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", - 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{}}}', + 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.address":{},"server.port":{}}}', 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), 'gen_ai.response.model': 'claude-3-haiku-20240307', @@ -463,12 +482,15 @@ def test_sync_messages_stream(instrumented_client: anthropic.Anthropic, exporter 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'logfire.tags': ('LLM',), + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'duration': 1.0, 'response_data': '{"combined_chunk_content":"The answer is secret","chunk_count":2}', - 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"response_data":{"type":"object"}}}', + 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.address":{},"server.port":{},"response_data":{"type":"object"}}}', 'gen_ai.response.model': 'claude-3-haiku-20240307', }, }, @@ -497,7 +519,7 @@ async def test_async_messages_stream( assert exporter.exported_spans_as_dict() == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -510,12 +532,14 @@ async def test_async_messages_stream( 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'async': True, + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", - 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{}}}', + 'logfire.json_schema': '{"type":"object","properties":{"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.port":{}}}', 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), 'gen_ai.response.model': 'claude-3-haiku-20240307', @@ -540,12 +564,14 @@ async def test_async_messages_stream( 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': '[{"role":"user","parts":[{"type":"text","content":"What is four plus five?"}]}]', 'gen_ai.system_instructions': '[{"type":"text","content":"You are a helpful assistant."}]', 'logfire.tags': ('LLM',), + 'server.port': 443, 'duration': 1.0, 'response_data': '{"combined_chunk_content":"The answer is secret","chunk_count":2}', - 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"response_data":{"type":"object"}}}', + 'logfire.json_schema': '{"type":"object","properties":{"duration":{},"request_data":{"type":"object"},"gen_ai.provider.name":{},"gen_ai.operation.name":{},"gen_ai.request.model":{},"gen_ai.request.max_tokens":{},"gen_ai.input.messages":{"type":"array"},"gen_ai.system_instructions":{"type":"array"},"async":{},"server.port":{},"response_data":{"type":"object"}}}', 'gen_ai.response.model': 'claude-3-haiku-20240307', }, }, @@ -565,7 +591,7 @@ def test_tool_messages(instrumented_client: anthropic.Anthropic, exporter: TestE assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Message with {request_data[model]!r}', + 'name': 'chat claude-3-haiku-20240307', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -583,9 +609,12 @@ def test_tool_messages(instrumented_client: anthropic.Anthropic, exporter: TestE 'gen_ai.provider.name': 'anthropic', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.max_tokens': 1000, 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'tool response'}], 'async': False, + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'logfire.msg_template': 'Message with {request_data[model]!r}', 'logfire.msg': "Message with 'claude-3-haiku-20240307'", 'logfire.span_type': 'span', @@ -626,9 +655,12 @@ def test_tool_messages(instrumented_client: anthropic.Anthropic, exporter: TestE 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, + 'gen_ai.request.max_tokens': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.address': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -668,6 +700,8 @@ def test_unknown_method(instrumented_client: anthropic.Anthropic, exporter: Test 'gen_ai.provider.name': 'anthropic', 'gen_ai.request.model': 'claude-2.1', 'logfire.msg_template': 'Anthropic API call to {url!r}', + 'server.address': 'api.anthropic.com', + 'server.port': 443, 'logfire.msg': "Anthropic API call to '/v1/complete'", 'code.filepath': 'test_anthropic.py', 'code.function': 'test_unknown_method', diff --git a/tests/otel_integrations/test_openai.py b/tests/otel_integrations/test_openai.py index 82caf6e48..1e97f4ccc 100644 --- a/tests/otel_integrations/test_openai.py +++ b/tests/otel_integrations/test_openai.py @@ -403,7 +403,7 @@ def test_sync_chat_completions(instrumented_client: openai.Client, exporter: Tes assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -430,11 +430,11 @@ def test_sync_chat_completions(instrumented_client: openai.Client, exporter: Tes ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.response.model': 'gpt-4', 'operation.cost': 0.00012, 'gen_ai.response.id': 'test_id', @@ -476,7 +476,7 @@ def test_sync_chat_completions(instrumented_client: openai.Client, exporter: Tes 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.response.model': {}, 'operation.cost': {}, 'gen_ai.response.id': {}, @@ -519,7 +519,7 @@ async def test_async_chat_completions(instrumented_async_client: openai.AsyncCli assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -546,11 +546,12 @@ async def test_async_chat_completions(instrumented_async_client: openai.AsyncCli ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'async': True, + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.response.model': 'gpt-4', 'operation.cost': 0.00012, 'gen_ai.response.id': 'test_id', @@ -592,7 +593,8 @@ async def test_async_chat_completions(instrumented_async_client: openai.AsyncCli 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, - 'gen_ai.system': {}, + 'server.address': {}, + 'server.port': {}, 'gen_ai.response.model': {}, 'operation.cost': {}, 'gen_ai.response.id': {}, @@ -634,7 +636,7 @@ def test_sync_chat_empty_response_chunk(instrumented_client: openai.Client, expo assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -654,6 +656,7 @@ def test_sync_chat_empty_response_chunk(instrumented_client: openai.Client, expo 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'empty response chunk'}], 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -666,6 +669,7 @@ def test_sync_chat_empty_response_chunk(instrumented_client: openai.Client, expo 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.port': {}, }, }, 'logfire.span_type': 'span', @@ -699,6 +703,7 @@ def test_sync_chat_empty_response_chunk(instrumented_client: openai.Client, expo 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'empty response chunk'}], 'logfire.tags': ('LLM',), + 'server.port': 443, 'duration': 1.0, 'response_data': {'combined_chunk_content': '', 'chunk_count': 0}, 'logfire.json_schema': { @@ -712,6 +717,7 @@ def test_sync_chat_empty_response_chunk(instrumented_client: openai.Client, expo 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.port': {}, 'response_data': {'type': 'object'}, }, }, @@ -734,7 +740,7 @@ def test_sync_chat_empty_response_choices(instrumented_client: openai.Client, ex assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -754,6 +760,7 @@ def test_sync_chat_empty_response_choices(instrumented_client: openai.Client, ex 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'empty choices in response chunk'}], 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -766,6 +773,7 @@ def test_sync_chat_empty_response_choices(instrumented_client: openai.Client, ex 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.port': {}, }, }, 'logfire.span_type': 'span', @@ -799,6 +807,7 @@ def test_sync_chat_empty_response_choices(instrumented_client: openai.Client, ex 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'empty choices in response chunk'}], 'logfire.tags': ('LLM',), + 'server.port': 443, 'duration': 1.0, 'response_data': {'message': None, 'usage': None}, 'logfire.json_schema': { @@ -812,6 +821,7 @@ def test_sync_chat_empty_response_choices(instrumented_client: openai.Client, ex 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.port': {}, 'response_data': {'type': 'object'}, }, }, @@ -862,7 +872,7 @@ def test_sync_chat_tool_call_stream(instrumented_client: openai.Client, exporter assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -901,9 +911,30 @@ def test_sync_chat_tool_call_stream(instrumented_client: openai.Client, exporter 'gen_ai.provider.name': 'openai', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.tool.definitions': [ + { + 'type': 'function', + 'function': { + 'name': 'get_current_weather', + 'description': 'Get the current weather in a given location', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, + }, + } + ], 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'streamed tool call'}], 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -913,9 +944,11 @@ def test_sync_chat_tool_call_stream(instrumented_client: openai.Client, exporter 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, + 'gen_ai.tool.definitions': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.port': {}, }, }, 'logfire.tags': ('LLM',), @@ -968,9 +1001,30 @@ def test_sync_chat_tool_call_stream(instrumented_client: openai.Client, exporter 'gen_ai.provider.name': 'openai', 'gen_ai.operation.name': 'chat', 'async': False, + 'gen_ai.tool.definitions': [ + { + 'type': 'function', + 'function': { + 'name': 'get_current_weather', + 'description': 'Get the current weather in a given location', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, + }, + } + ], 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'streamed tool call'}], 'duration': 1.0, + 'server.port': 443, 'response_data': { 'message': { 'content': None, @@ -1009,9 +1063,11 @@ def test_sync_chat_tool_call_stream(instrumented_client: openai.Client, exporter 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, + 'gen_ai.tool.definitions': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -1098,7 +1154,7 @@ async def test_async_chat_tool_call_stream( assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1137,9 +1193,31 @@ async def test_async_chat_tool_call_stream( 'gen_ai.provider.name': 'openai', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.tool.definitions': [ + { + 'type': 'function', + 'function': { + 'name': 'get_current_weather', + 'description': 'Get the current weather in a given location', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, + }, + } + ], 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'streamed tool call'}], 'async': True, + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -1149,9 +1227,12 @@ async def test_async_chat_tool_call_stream( 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, + 'gen_ai.tool.definitions': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.address': {}, + 'server.port': {}, }, }, 'logfire.tags': ('LLM',), @@ -1204,9 +1285,31 @@ async def test_async_chat_tool_call_stream( 'gen_ai.provider.name': 'openai', 'gen_ai.operation.name': 'chat', 'async': True, + 'gen_ai.tool.definitions': [ + { + 'type': 'function', + 'function': { + 'name': 'get_current_weather', + 'description': 'Get the current weather in a given location', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, + }, + } + ], 'gen_ai.input.messages': [], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'streamed tool call'}], 'duration': 1.0, + 'server.address': 'api.openai.com', + 'server.port': 443, 'response_data': { 'message': { 'content': None, @@ -1245,9 +1348,12 @@ async def test_async_chat_tool_call_stream( 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, + 'gen_ai.tool.definitions': {}, 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.address': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -1304,7 +1410,7 @@ def test_sync_chat_completions_stream(instrumented_client: openai.Client, export assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1329,6 +1435,7 @@ def test_sync_chat_completions_stream(instrumented_client: openai.Client, export ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -1341,6 +1448,7 @@ def test_sync_chat_completions_stream(instrumented_client: openai.Client, export 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.port': {}, }, }, 'logfire.span_type': 'span', @@ -1379,6 +1487,7 @@ def test_sync_chat_completions_stream(instrumented_client: openai.Client, export ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'logfire.tags': ('LLM',), + 'server.port': 443, 'duration': 1.0, 'response_data': { 'message': { @@ -1404,6 +1513,7 @@ def test_sync_chat_completions_stream(instrumented_client: openai.Client, export 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -1440,7 +1550,7 @@ async def test_async_chat_completions_stream( assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Chat Completion with {request_data[model]!r}', + 'name': 'chat gpt-4', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1465,6 +1575,8 @@ async def test_async_chat_completions_stream( ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'async': True, + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg_template': 'Chat Completion with {request_data[model]!r}', 'logfire.msg': "Chat Completion with 'gpt-4'", 'logfire.json_schema': { @@ -1477,6 +1589,8 @@ async def test_async_chat_completions_stream( 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'async': {}, + 'server.address': {}, + 'server.port': {}, }, }, 'logfire.span_type': 'span', @@ -1515,6 +1629,8 @@ async def test_async_chat_completions_stream( ], 'gen_ai.system_instructions': [{'type': 'text', 'content': 'You are a helpful assistant.'}], 'logfire.tags': ('LLM',), + 'server.address': 'api.openai.com', + 'server.port': 443, 'duration': 1.0, 'response_data': { 'message': { @@ -1540,6 +1656,8 @@ async def test_async_chat_completions_stream( 'gen_ai.input.messages': {'type': 'array'}, 'gen_ai.system_instructions': {'type': 'array'}, 'duration': {}, + 'server.address': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -1568,7 +1686,7 @@ def test_completions(instrumented_client: openai.Client, exporter: TestExporter) assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Completion with {request_data[model]!r}', + 'name': 'text_completion gpt-3.5-turbo-instruct', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1582,10 +1700,10 @@ def test_completions(instrumented_client: openai.Client, exporter: TestExporter) 'gen_ai.operation.name': 'text_completion', 'async': False, 'logfire.msg_template': 'Completion with {request_data[model]!r}', + 'server.port': 443, 'logfire.msg': "Completion with 'gpt-3.5-turbo-instruct'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo-instruct', 'gen_ai.response.model': 'gpt-3.5-turbo-instruct', 'gen_ai.usage.input_tokens': 2, @@ -1614,7 +1732,7 @@ def test_completions(instrumented_client: openai.Client, exporter: TestExporter) 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.request.model': {}, 'gen_ai.response.model': {}, 'gen_ai.usage.input_tokens': {}, @@ -1756,7 +1874,7 @@ def test_completions_stream(instrumented_client: openai.Client, exporter: TestEx assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Completion with {request_data[model]!r}', + 'name': 'text_completion gpt-3.5-turbo-instruct', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1774,6 +1892,7 @@ def test_completions_stream(instrumented_client: openai.Client, exporter: TestEx 'gen_ai.operation.name': 'text_completion', 'gen_ai.request.model': 'gpt-3.5-turbo-instruct', 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Completion with {request_data[model]!r}', 'logfire.msg': "Completion with 'gpt-3.5-turbo-instruct'", 'logfire.json_schema': { @@ -1784,6 +1903,7 @@ def test_completions_stream(instrumented_client: openai.Client, exporter: TestEx 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, 'async': {}, + 'server.port': {}, }, }, 'logfire.span_type': 'span', @@ -1815,6 +1935,7 @@ def test_completions_stream(instrumented_client: openai.Client, exporter: TestEx 'gen_ai.operation.name': 'text_completion', 'logfire.span_type': 'log', 'logfire.tags': ('LLM',), + 'server.port': 443, 'duration': 1.0, 'response_data': {'combined_chunk_content': 'The answer is Nine', 'chunk_count': 2}, 'logfire.json_schema': { @@ -1826,6 +1947,7 @@ def test_completions_stream(instrumented_client: openai.Client, exporter: TestEx 'gen_ai.operation.name': {}, 'async': {}, 'duration': {}, + 'server.port': {}, 'response_data': {'type': 'object'}, }, }, @@ -1845,7 +1967,7 @@ def test_embeddings(instrumented_client: openai.Client, exporter: TestExporter) assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Embedding Creation with {request_data[model]!r}', + 'name': 'embeddings text-embedding-3-small', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1863,10 +1985,10 @@ def test_embeddings(instrumented_client: openai.Client, exporter: TestExporter) 'gen_ai.operation.name': 'embeddings', 'async': False, 'logfire.msg_template': 'Embedding Creation with {request_data[model]!r}', + 'server.port': 443, 'logfire.msg': "Embedding Creation with 'text-embedding-3-small'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.request.model': 'text-embedding-3-small', 'gen_ai.response.model': 'text-embedding-3-small', 'gen_ai.usage.input_tokens': 1, @@ -1878,7 +2000,7 @@ def test_embeddings(instrumented_client: openai.Client, exporter: TestExporter) 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.request.model': {}, 'gen_ai.response.model': {}, 'gen_ai.usage.input_tokens': {}, @@ -1907,7 +2029,7 @@ def test_images(instrumented_client: openai.Client, exporter: TestExporter) -> N assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( [ { - 'name': 'Image Generation with {request_data[model]!r}', + 'name': 'generate_content dall-e-3', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -1921,11 +2043,11 @@ def test_images(instrumented_client: openai.Client, exporter: TestExporter) -> N 'gen_ai.operation.name': 'generate_content', 'gen_ai.request.model': 'dall-e-3', 'async': False, + 'server.port': 443, 'logfire.msg_template': 'Image Generation with {request_data[model]!r}', 'logfire.msg': "Image Generation with 'dall-e-3'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'response_data': { 'images': [ { @@ -1943,7 +2065,7 @@ def test_images(instrumented_client: openai.Client, exporter: TestExporter) -> N 'gen_ai.operation.name': {}, 'gen_ai.request.model': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'response_data': { 'type': 'object', 'properties': { @@ -2035,7 +2157,7 @@ def test_dont_suppress_httpx(exporter: TestExporter) -> None: }, }, { - 'name': 'Completion with {request_data[model]!r}', + 'name': 'text_completion gpt-3.5-turbo-instruct', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -2050,10 +2172,11 @@ def test_dont_suppress_httpx(exporter: TestExporter) -> None: 'gen_ai.operation.name': 'text_completion', 'async': False, 'logfire.msg_template': 'Completion with {request_data[model]!r}', + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg': "Completion with 'gpt-3.5-turbo-instruct'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo-instruct', 'gen_ai.response.model': 'gpt-3.5-turbo-instruct', 'gen_ai.usage.input_tokens': 2, @@ -2082,7 +2205,8 @@ def test_dont_suppress_httpx(exporter: TestExporter) -> None: 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.address': {}, + 'server.port': {}, 'gen_ai.request.model': {}, 'gen_ai.response.model': {}, 'gen_ai.usage.input_tokens': {}, @@ -2154,7 +2278,7 @@ def test_suppress_httpx(exporter: TestExporter) -> None: assert exporter.exported_spans_as_dict(parse_json_attributes=True, include_instrumentation_scope=True) == snapshot( [ { - 'name': 'Completion with {request_data[model]!r}', + 'name': 'text_completion gpt-3.5-turbo-instruct', 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, @@ -2169,10 +2293,11 @@ def test_suppress_httpx(exporter: TestExporter) -> None: 'gen_ai.operation.name': 'text_completion', 'async': False, 'logfire.msg_template': 'Completion with {request_data[model]!r}', + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg': "Completion with 'gpt-3.5-turbo-instruct'", 'logfire.span_type': 'span', 'logfire.tags': ('LLM',), - 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo-instruct', 'gen_ai.response.model': 'gpt-3.5-turbo-instruct', 'gen_ai.usage.input_tokens': 2, @@ -2201,7 +2326,8 @@ def test_suppress_httpx(exporter: TestExporter) -> None: 'gen_ai.provider.name': {}, 'gen_ai.operation.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.address': {}, + 'server.port': {}, 'gen_ai.request.model': {}, 'gen_ai.response.model': {}, 'gen_ai.usage.input_tokens': {}, @@ -2271,11 +2397,11 @@ def test_create_files(instrumented_client: openai.Client, exporter: TestExporter 'async': False, 'gen_ai.provider.name': 'openai', 'logfire.msg_template': 'OpenAI API call to {url!r}', + 'server.port': 443, 'logfire.msg': "OpenAI API call to '/files'", 'code.filepath': 'test_openai.py', 'code.function': 'test_create_files', 'code.lineno': 123, - 'gen_ai.system': 'openai', 'gen_ai.response.id': 'test_id', 'logfire.json_schema': { 'type': 'object', @@ -2284,7 +2410,7 @@ def test_create_files(instrumented_client: openai.Client, exporter: TestExporter 'url': {}, 'gen_ai.provider.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.response.id': {}, }, }, @@ -2313,11 +2439,12 @@ async def test_create_files_async(instrumented_async_client: openai.AsyncClient, 'async': True, 'gen_ai.provider.name': 'openai', 'logfire.msg_template': 'OpenAI API call to {url!r}', + 'server.address': 'api.openai.com', + 'server.port': 443, 'logfire.msg': "OpenAI API call to '/files'", 'code.filepath': 'test_openai.py', 'code.function': 'test_create_files_async', 'code.lineno': 123, - 'gen_ai.system': 'openai', 'gen_ai.response.id': 'test_id', 'logfire.json_schema': { 'type': 'object', @@ -2326,7 +2453,8 @@ async def test_create_files_async(instrumented_async_client: openai.AsyncClient, 'url': {}, 'gen_ai.provider.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.address': {}, + 'server.port': {}, 'gen_ai.response.id': {}, }, }, @@ -2368,10 +2496,10 @@ def test_create_assistant(instrumented_client: openai.Client, exporter: TestExpo 'gen_ai.provider.name': 'openai', 'logfire.msg_template': 'OpenAI API call to {url!r}', 'logfire.msg': "OpenAI API call to '/assistants'", + 'server.port': 443, 'code.filepath': 'test_openai.py', 'code.function': 'test_create_assistant', 'code.lineno': 123, - 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4o', 'gen_ai.response.model': 'gpt-4-turbo', 'gen_ai.response.id': 'asst_abc123', @@ -2382,7 +2510,7 @@ def test_create_assistant(instrumented_client: openai.Client, exporter: TestExpo 'url': {}, 'gen_ai.provider.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.request.model': {}, 'gen_ai.response.model': {}, 'gen_ai.response.id': {}, @@ -2414,11 +2542,11 @@ def test_create_thread(instrumented_client: openai.Client, exporter: TestExporte 'async': False, 'gen_ai.provider.name': 'openai', 'logfire.msg_template': 'OpenAI API call to {url!r}', + 'server.port': 443, 'logfire.msg': "OpenAI API call to '/threads'", 'code.filepath': 'test_openai.py', 'code.function': 'test_create_thread', 'code.lineno': 123, - 'gen_ai.system': 'openai', 'gen_ai.response.id': 'thread_abc123', 'logfire.json_schema': { 'type': 'object', @@ -2427,7 +2555,7 @@ def test_create_thread(instrumented_client: openai.Client, exporter: TestExporte 'url': {}, 'gen_ai.provider.name': {}, 'async': {}, - 'gen_ai.system': {}, + 'server.port': {}, 'gen_ai.response.id': {}, }, },